mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
Add RTL rendering for Rhyhorn template (#3099)
* Add RTL rendering for Rhyhorn template * Add timeout to wait-healthy just command * Revert prettier formatting * Revert and ignore personal relevant files * Revert prettier formatting from all modified files * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { useRender } from "../../context";
|
||||
import { Image, Page, StyleSheet, View } from "../../renderer";
|
||||
import { CustomFieldContactItem, WebsiteContactItem } from "../shared/contact-item";
|
||||
@@ -161,6 +162,7 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const rtl = isRTL(metadata.page.locale);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
@@ -168,6 +170,7 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
...(rtl ? { direction: "rtl" as const, textAlign: "right" as const } : {}),
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
@@ -180,6 +183,7 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
direction: rtl ? "rtl" : "ltr",
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
@@ -188,49 +192,80 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
...(rtl ? { direction: "rtl" as const, textAlign: "right" as const } : {}),
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: rtl ? "row-reverse" : "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemRow: {
|
||||
// Stays `row` for both LTR and RTL; the <li> renderer swaps DOM order for RTL.
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
// bodyText spread first so `textAlign` below isn't clobbered by bodyText.textAlign.
|
||||
...bodyText,
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: rtl ? "left" : "right",
|
||||
},
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexDirection: rtl ? "row-reverse" : "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
alignRight: { textAlign: rtl ? "left" : "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: { color: primary, borderBottomWidth: 1, borderBottomColor: primary },
|
||||
sectionHeading: {
|
||||
color: primary,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
textAlign: rtl ? "right" : "left",
|
||||
},
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
header: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(0.5) },
|
||||
header: { flexDirection: rtl ? "row-reverse" : "row", alignItems: "center", columnGap: metrics.gapX(0.5) },
|
||||
headerTitle: { flex: 1, rowGap: metrics.gapY(0.5) },
|
||||
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
|
||||
headerIdentity: {
|
||||
textAlign: rtl ? "right" : "left",
|
||||
alignItems: rtl ? "flex-end" : "flex-start",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: headerNameLineHeight },
|
||||
contactList: { flexDirection: "row", flexWrap: "wrap", rowGap: metrics.gapY(0.125) },
|
||||
contactList: { flexDirection: rtl ? "row-reverse" : "row", flexWrap: "wrap", rowGap: metrics.gapY(0.125) },
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
flexDirection: rtl ? "row-reverse" : "row",
|
||||
alignItems: "center",
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: primary,
|
||||
paddingRight: metrics.gapX(0.5),
|
||||
marginRight: metrics.gapX(0.5),
|
||||
...(rtl
|
||||
? {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: primary,
|
||||
paddingLeft: metrics.gapX(0.5),
|
||||
marginLeft: metrics.gapX(0.5),
|
||||
}
|
||||
: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: primary,
|
||||
paddingRight: metrics.gapX(0.5),
|
||||
marginRight: metrics.gapX(0.5),
|
||||
}),
|
||||
},
|
||||
contactItemContent: {
|
||||
flexDirection: "row",
|
||||
flexDirection: rtl ? "row-reverse" : "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
contactItemLast: { borderRightWidth: 0, paddingRight: 0, marginRight: 0 },
|
||||
contactItemLast: rtl
|
||||
? { borderLeftWidth: 0, paddingLeft: 0, marginLeft: 0 }
|
||||
: { borderRightWidth: 0, paddingRight: 0, marginRight: 0 },
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactElement } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHtml } from "react-pdf-html";
|
||||
import { Text as PdfText } from "../../renderer";
|
||||
import { normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
|
||||
import { convertPseudoBulletParagraphs, normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
|
||||
|
||||
type PdfElement = ReactElement<{ children?: unknown; element?: { tag: string } }>;
|
||||
|
||||
@@ -81,3 +81,54 @@ describe("normalizeRichTextHtml", () => {
|
||||
expect(normalizeRichTextHtml("<p><strong>x</strong></p>")).toBe("<p><strong>x</strong></p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertPseudoBulletParagraphs", () => {
|
||||
it("converts a <p> of dash-prefixed lines into a <ul><li> list", () => {
|
||||
expect(convertPseudoBulletParagraphs("<p>- a<br>- b<br>- c</p>")).toBe("<ul><li>a</li><li>b</li><li>c</li></ul>");
|
||||
});
|
||||
|
||||
it("handles <br> wrapped in inline formatting tags (real editor output)", () => {
|
||||
expect(convertPseudoBulletParagraphs("<p>- <strong></strong>foo.<strong><br></strong>- bar.</p>")).toBe(
|
||||
"<ul><li>foo.</li><li>bar.</li></ul>",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts other bullet markers (• and *)", () => {
|
||||
expect(convertPseudoBulletParagraphs("<p>• one<br>* two</p>")).toBe("<ul><li>one</li><li>two</li></ul>");
|
||||
});
|
||||
|
||||
it("leaves a paragraph with a single inline <br> untouched", () => {
|
||||
const input = "<p>Just a paragraph with a <br> line break.</p>";
|
||||
expect(convertPseudoBulletParagraphs(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("leaves a single-line dash paragraph untouched (no <br>)", () => {
|
||||
const input = "<p>- Just one line</p>";
|
||||
expect(convertPseudoBulletParagraphs(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("does not convert when one segment lacks a leading bullet marker", () => {
|
||||
const input = "<p>- foo<br>bar without dash</p>";
|
||||
expect(convertPseudoBulletParagraphs(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("leaves a real <ul> alone", () => {
|
||||
const input = "<ul><li>a</li><li>b</li></ul>";
|
||||
expect(convertPseudoBulletParagraphs(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("only converts matching paragraphs in mixed input", () => {
|
||||
const input = "<p>- a<br>- b</p><p>just a normal paragraph.</p>";
|
||||
expect(convertPseudoBulletParagraphs(input)).toBe("<ul><li>a</li><li>b</li></ul><p>just a normal paragraph.</p>");
|
||||
});
|
||||
|
||||
it("preserves non-empty inline formatting inside bullet text", () => {
|
||||
expect(convertPseudoBulletParagraphs("<p>- foo <strong>bold</strong> bar<br>- baz</p>")).toBe(
|
||||
"<ul><li>foo <strong>bold</strong> bar</li><li>baz</li></ul>",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates BiDi marks (LRM/RLM) before the bullet character", () => {
|
||||
expect(convertPseudoBulletParagraphs("<p>- א<br>- ב</p>")).toBe("<ul><li>א</li><li>ב</li></ul>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,39 @@ const isInlineNode = (node: Node): boolean => {
|
||||
return inlineTags.has(getTagName(node)) && !hasBlockDescendant(node);
|
||||
};
|
||||
|
||||
// Allow optional leading whitespace + LRM/RLM marks before the bullet character.
|
||||
const PSEUDO_BULLET_LEAD = /^[\s]*[-•*]\s+/;
|
||||
|
||||
const stripEmptyInlineWrappers = (html: string): string =>
|
||||
html.replace(/<(strong|b|em|i|u|span)\b[^>]*>\s*<\/\1>/gi, "");
|
||||
|
||||
// Treat a bare <br> or one wrapped in an inline tag (e.g. `<strong><br></strong>` from
|
||||
// the editor) as the segment separator.
|
||||
const splitByBreaks = (html: string): string[] =>
|
||||
html.split(/(?:<(?:strong|b|em|i|u|span)\b[^>]*>\s*<br\b[^>]*\/?>\s*<\/(?:strong|b|em|i|u|span)>)|<br\b[^>]*\/?>/gi);
|
||||
|
||||
const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | null => {
|
||||
const cleaned = stripEmptyInlineWrappers(paragraphInnerHtml);
|
||||
if (!/<br\b/i.test(cleaned)) return null;
|
||||
|
||||
const segments = splitByBreaks(cleaned)
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
|
||||
if (segments.length < 2) return null;
|
||||
if (!segments.every((segment) => PSEUDO_BULLET_LEAD.test(segment))) return null;
|
||||
|
||||
const items = segments.map((segment) => segment.replace(PSEUDO_BULLET_LEAD, ""));
|
||||
|
||||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||||
};
|
||||
|
||||
export const convertPseudoBulletParagraphs = (html: string): string =>
|
||||
html.replace(/<p\b([^>]*)>([\s\S]*?)<\/p>/gi, (full, _attrs, inner) => {
|
||||
const converted = tryConvertPseudoBulletParagraph(inner);
|
||||
return converted ?? full;
|
||||
});
|
||||
|
||||
export const normalizeRichTextHtml = (html: string): string => {
|
||||
const root = parse(html.trim(), { comment: false });
|
||||
const normalized: string[] = [];
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { cloneElement, isValidElement } from "react";
|
||||
import { Html } from "react-pdf-html";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { useRender } from "../../context";
|
||||
import { Text as PdfText, View } from "../../renderer";
|
||||
import { useTemplateStyle } from "./context";
|
||||
import { safeTextStyle } from "./primitives";
|
||||
import { normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
|
||||
import { convertPseudoBulletParagraphs, normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
|
||||
import {
|
||||
createRichTextProseSpacing,
|
||||
getRichTextEdgeTrimStyle,
|
||||
@@ -29,7 +33,41 @@ const toStyleArray = (style: Style | Style[] | undefined): Style[] => {
|
||||
return [style];
|
||||
};
|
||||
|
||||
// react-pdf textkit reads BiDi base direction from each run's own `direction` attribute
|
||||
// (default "ltr"), and react-pdf-html buckets inline content into styleless inner <Text>
|
||||
// frames — so the rtl style has to be injected onto every descendant, not just a wrapper.
|
||||
const applyRtlDirectionRecursively = (node: ReactNode): ReactNode => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((child, i) => {
|
||||
const cloned = applyRtlDirectionRecursively(child);
|
||||
if (isValidElement(cloned) && cloned.key == null) {
|
||||
return cloneElement(cloned as ReactElement<{ key?: string }>, {
|
||||
key: `rtl-${i}`,
|
||||
});
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
if (!isValidElement(node)) return node;
|
||||
const element = node as ReactElement<{
|
||||
style?: Style | Style[];
|
||||
children?: ReactNode;
|
||||
}>;
|
||||
const existingStyle = element.props.style;
|
||||
const rtlPatch: Style = { direction: "rtl", textAlign: "right" };
|
||||
const nextStyle: Style | Style[] = Array.isArray(existingStyle)
|
||||
? [...existingStyle, rtlPatch]
|
||||
: existingStyle
|
||||
? [existingStyle, rtlPatch]
|
||||
: rtlPatch;
|
||||
const nextChildren = applyRtlDirectionRecursively(element.props.children);
|
||||
return cloneElement(element, { style: nextStyle }, nextChildren);
|
||||
};
|
||||
|
||||
export const RichText = ({ children }: { children: string }) => {
|
||||
const data = useRender();
|
||||
const rtl = isRTL(data.metadata.page.locale);
|
||||
const rtlTextWrapStyle: Style | undefined = rtl ? { direction: "rtl", textAlign: "right" } : undefined;
|
||||
const boldStyle = useTemplateStyle("bold");
|
||||
const linkStyle = useTemplateStyle("link");
|
||||
const richParagraphStyle = useTemplateStyle("richParagraph");
|
||||
@@ -39,7 +77,16 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
const bodyLineHeight = resolveRichTextBodyLineHeight(richParagraphStyle, richListItemContentStyle);
|
||||
const proseSpacing = createRichTextProseSpacing(bodyLineHeight);
|
||||
|
||||
const html = normalizeRichTextHtml(children);
|
||||
const normalized = normalizeRichTextHtml(children);
|
||||
// RTL-only: pseudo-bullets share one BiDi paragraph across <br>, causing chars to
|
||||
// bleed between visual lines. Real <li> items are independent BiDi paragraphs.
|
||||
const withBullets = normalized && rtl ? convertPseudoBulletParagraphs(normalized) : normalized;
|
||||
// Inject U+200F (RLM) after each <p>/<li> opener to anchor BiDi base direction
|
||||
// on the inner styleless <Text> frame react-pdf-html creates.
|
||||
const html =
|
||||
withBullets && rtl
|
||||
? withBullets.replace(/<(p|li)\b([^>]*)>/gi, (_match, tag, rest) => `<${tag}${rest}>`)
|
||||
: withBullets;
|
||||
|
||||
if (!html) return null;
|
||||
|
||||
@@ -53,6 +100,14 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
? toStyleArray(style).map(stripRichTextVerticalMargins)
|
||||
: style;
|
||||
|
||||
if (rtl) {
|
||||
return (
|
||||
<PdfText style={composeStyles(paragraphStyles, getRichTextEdgeTrimStyle(element), rtlTextWrapStyle)}>
|
||||
{applyRtlDirectionRecursively(children)}
|
||||
</PdfText>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={composeStyles(paragraphStyles, getRichTextEdgeTrimStyle(element))}>{children}</View>;
|
||||
},
|
||||
li: ({ element, style, children }) => {
|
||||
@@ -61,19 +116,38 @@ export const RichText = ({ children }: { children: string }) => {
|
||||
const itemStyles = toStyleArray(style);
|
||||
const contentItemStyles = itemStyles.map(stripRichTextVerticalMargins);
|
||||
|
||||
const markerNode = (
|
||||
<PdfText key="marker" style={composeStyles(richListItemMarkerStyle)}>
|
||||
{marker}
|
||||
</PdfText>
|
||||
);
|
||||
// Same BiDi-injection trick as the <p> renderer — see applyRtlDirectionRecursively.
|
||||
const contentNode = rtl ? (
|
||||
<PdfText
|
||||
key="content"
|
||||
style={composeStyles(richListItemContentStyle, contentItemStyles, safeTextStyle, rtlTextWrapStyle)}
|
||||
>
|
||||
{applyRtlDirectionRecursively(children)}
|
||||
</PdfText>
|
||||
) : (
|
||||
<View
|
||||
key="content"
|
||||
style={composeStyles(
|
||||
richListItemContentStyle,
|
||||
contentItemStyles,
|
||||
richListItemContentStackStyle,
|
||||
safeTextStyle,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
// Yoga ignores `flexDirection`/`direction` on rows inside react-pdf-html's <ul>
|
||||
// (works fine for split-row/contact-list). Swap DOM order to position the marker.
|
||||
return (
|
||||
<View style={composeStyles(richListItemRowStyle, itemStyles, getRichTextEdgeTrimStyle(element))}>
|
||||
<PdfText style={composeStyles(richListItemMarkerStyle)}>{marker}</PdfText>
|
||||
<View
|
||||
style={composeStyles(
|
||||
richListItemContentStyle,
|
||||
contentItemStyles,
|
||||
richListItemContentStackStyle,
|
||||
safeTextStyle,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
{rtl ? [contentNode, markerNode] : [markerNode, contentNode]}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
import type { CustomItemSection, ItemSection } from "./types";
|
||||
import { Children, createContext, isValidElement, use } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { useRender } from "../../context";
|
||||
import { View } from "../../renderer";
|
||||
import { getResumeSectionTitle } from "../../section-title";
|
||||
@@ -117,6 +118,8 @@ const SectionItems = ({ children, columns = 1 }: { children: ReactNode; columns?
|
||||
columns: layout.columns,
|
||||
});
|
||||
const context = { itemStyle: layout.itemStyle, useTimeline };
|
||||
const rtl = isRTL(data.metadata.page.locale);
|
||||
const rtlRowStyle = rtl ? { flexDirection: "row-reverse" as const } : undefined;
|
||||
|
||||
if (!useTimeline) {
|
||||
if (layout.isGrid) {
|
||||
@@ -126,7 +129,7 @@ const SectionItems = ({ children, columns = 1 }: { children: ReactNode; columns?
|
||||
<SectionItemsContext.Provider value={context}>
|
||||
<View style={composeStyles(layout.containerStyle, sectionItemsStyle)}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<View key={getRowKey(row, rowIndex)} style={composeStyles(layout.rowStyle)}>
|
||||
<View key={getRowKey(row, rowIndex)} style={composeStyles(layout.rowStyle, rtlRowStyle)}>
|
||||
{row}
|
||||
{SECTION_ITEM_PLACEHOLDER_KEYS.slice(0, layout.columns - row.length).map((placeholderKey) => (
|
||||
<View key={placeholderKey} style={composeStyles(layout.itemStyle)} />
|
||||
|
||||
@@ -69,3 +69,21 @@ export function isLocale(value: unknown): value is Locale {
|
||||
export function isCJKLocale(locale: Locale): boolean {
|
||||
return locale === "zh-CN" || locale === "zh-TW" || locale === "ja-JP" || locale === "ko-KR";
|
||||
}
|
||||
|
||||
const RTL_LANGUAGES = new Set([
|
||||
"ar", // Arabic
|
||||
"ckb", // Kurdish (Sorani)
|
||||
"dv", // Dhivehi
|
||||
"fa", // Persian
|
||||
"he", // Hebrew
|
||||
"ps", // Pashto
|
||||
"sd", // Sindhi
|
||||
"ug", // Uyghur
|
||||
"ur", // Urdu
|
||||
"yi", // Yiddish
|
||||
]);
|
||||
|
||||
export function isRTL(locale: string): boolean {
|
||||
const language = locale.split("-")[0].toLowerCase();
|
||||
return RTL_LANGUAGES.has(language);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user