diff --git a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx index e00000c58..2d99a29ac 100644 --- a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx +++ b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx @@ -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
x
")).toBe("x
"); }); }); + +describe("convertPseudoBulletParagraphs", () => { + it("converts aof dash-prefixed lines into a
- a
- b
- c
- foo.
- bar.
• one
* two
Just a paragraph with a
line break.
- Just one line
"; + expect(convertPseudoBulletParagraphs(input)).toBe(input); + }); + + it("does not convert when one segment lacks a leading bullet marker", () => { + const input = "- foo
bar without dash
- a
- b
just a normal paragraph.
"; + expect(convertPseudoBulletParagraphs(input)).toBe("just a normal paragraph.
"); + }); + + it("preserves non-empty inline formatting inside bullet text", () => { + expect(convertPseudoBulletParagraphs("- foo bold bar
- baz
- א
- ב
]*)>([\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[] = [];
diff --git a/packages/pdf/src/templates/shared/rich-text.tsx b/packages/pdf/src/templates/shared/rich-text.tsx
index 412386ebc..43ffc88fc 100644
--- a/packages/pdf/src/templates/shared/rich-text.tsx
+++ b/packages/pdf/src/templates/shared/rich-text.tsx
@@ -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 / renderer — see applyRtlDirectionRecursively.
+ const contentNode = rtl ? (
+
, causing chars to
+ // bleed between visual lines. Real
+ // (works fine for split-row/contact-list). Swap DOM order to position the marker.
return (