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:
Eyal Meschman
2026-05-25 16:46:51 +03:00
committed by GitHub
parent 6ec4da7914
commit 266bc291eb
6 changed files with 245 additions and 31 deletions
@@ -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[] = [];
+87 -13
View File
@@ -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)} />
+18
View File
@@ -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);
}