From f468651c799f3364f6f2aa271b744a74e7b73d78 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 10:41:30 -0700 Subject: [PATCH] feat(editor): support whole-paragraph indentation (#3448) * feat(editor): support whole-paragraph indentation * fix(docx): retain indentation in quotes and RTL documents * fix(exports): bound paragraph insets in narrow PDF columns * fix(pdf): type bounded paragraph rendering consistently * test(editor): use explicit list conversion commands * fix(docx): preserve quote inset on list items --- .../src/components/input/paragraph-indent.ts | 116 +++++++++ .../input/rich-input.indent.test.tsx | 237 ++++++++++++++++++ apps/web/src/components/input/rich-input.tsx | 22 +- packages/docx/src/builder.ts | 2 + packages/docx/src/html-to-docx.ts | 77 ++++-- packages/docx/src/paragraph-indent.test.ts | 77 ++++++ .../src/paragraph-indent.integration.test.tsx | 207 +++++++++++++++ .../src/templates/shared/rich-text-html.ts | 32 ++- .../templates/shared/rich-text-renderers.ts | 33 ++- .../pdf/src/templates/shared/rich-text.tsx | 12 +- 10 files changed, 777 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/input/paragraph-indent.ts create mode 100644 apps/web/src/components/input/rich-input.indent.test.tsx create mode 100644 packages/docx/src/paragraph-indent.test.ts create mode 100644 packages/pdf/src/paragraph-indent.integration.test.tsx diff --git a/apps/web/src/components/input/paragraph-indent.ts b/apps/web/src/components/input/paragraph-indent.ts new file mode 100644 index 000000000..391925cf5 --- /dev/null +++ b/apps/web/src/components/input/paragraph-indent.ts @@ -0,0 +1,116 @@ +import type { Command } from "@tiptap/react"; +import { Plugin } from "@tiptap/pm/state"; +import { commands, Extension } from "@tiptap/react"; + +// Persist integer levels, not physical CSS margins. Export adapters share this HTML contract. +const readIndent = (value: unknown): number => { + const level = Number(value); + return Number.isInteger(level) && level >= 0 && level <= 8 ? level : 0; +}; + +const changeIndent = + (step: -1 | 1): Command => + ({ state, tr, dispatch, commands }) => { + const { from, to, $from } = tr.selection; + for (let depth = $from.depth; depth > 0; depth--) { + if ($from.node(depth).type.name === "listItem") { + return step === 1 ? commands.sinkListItem("listItem") : commands.liftListItem("listItem"); + } + } + + let changed = false; + state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name === "listItem") return false; + if (node.type.name !== "paragraph" && node.type.name !== "heading") return; + const indent = Math.max(0, Math.min(8, readIndent(node.attrs.indent) + step)); + if (indent === readIndent(node.attrs.indent)) return; + changed = true; + if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent }); + }); + return changed; + }; + +declare module "@tiptap/react" { + interface Commands { + paragraphIndent: { + increaseIndent: () => ReturnType; + decreaseIndent: () => ReturnType; + }; + } +} + +export const ParagraphIndent = Extension.create({ + name: "paragraphIndent", + addGlobalAttributes() { + return [ + { + types: ["paragraph", "heading"], + attributes: { + indent: { + default: 0, + parseHTML: (element) => (element.closest("li") ? 0 : readIndent(element.getAttribute("data-indent"))), + renderHTML: (attributes) => { + const indent = readIndent(attributes.indent); + return indent ? { "data-indent": indent, style: `margin-inline-start: ${indent * 24}px` } : {}; + }, + }, + }, + }, + ]; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + appendTransaction: (transactions, _oldState, state) => { + if (!transactions.some((transaction) => transaction.docChanged)) return null; + const tr = state.tr; + // Lists own their indentation, including conversions triggered by input + // rules. Appending keeps normalization in the same undoable change. + state.doc.descendants((node, pos) => { + if (node.type.name !== "listItem") return; + node.descendants((child, offset) => { + if ((child.type.name === "paragraph" || child.type.name === "heading") && child.attrs.indent) { + tr.setNodeMarkup(pos + 1 + offset, undefined, { ...child.attrs, indent: 0 }); + } + }); + return false; + }); + return tr.docChanged ? tr : null; + }, + }), + ]; + }, + addCommands() { + return { + increaseIndent: () => changeIndent(1), + decreaseIndent: () => changeIndent(-1), + // Tiptap only copies source attributes for selections inside one block. + // Keep each block's level when a multi-block heading/paragraph conversion runs. + setNode: (type, attributes) => (props) => { + const name = typeof type === "string" ? type : type.name; + if ((name !== "paragraph" && name !== "heading") || attributes?.indent !== undefined) { + return commands.setNode(type, attributes)(props); + } + const { tr, dispatch } = props; + const mapStart = tr.mapping.maps.length; + const levels: { pos: number; indent: number }[] = []; + tr.doc.nodesBetween(tr.selection.from, tr.selection.to, (node, pos) => { + if (node.type.name === "paragraph" || node.type.name === "heading") { + levels.push({ pos, indent: readIndent(node.attrs.indent) }); + } + }); + const result = commands.setNode(type, attributes)(props); + if (result && dispatch) { + for (const { pos, indent } of levels) { + const mapped = tr.mapping.slice(mapStart).map(pos); + const node = tr.doc.nodeAt(mapped); + if (node?.type.name === name && node.attrs.indent !== indent) { + tr.setNodeMarkup(mapped, undefined, { ...node.attrs, indent }); + } + } + } + return result; + }, + }; + }, +}); diff --git a/apps/web/src/components/input/rich-input.indent.test.tsx b/apps/web/src/components/input/rich-input.indent.test.tsx new file mode 100644 index 000000000..a795a67ef --- /dev/null +++ b/apps/web/src/components/input/rich-input.indent.test.tsx @@ -0,0 +1,237 @@ +// @vitest-environment happy-dom + +import type { Editor } from "@tiptap/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { PromptDialogProvider } from "@/hooks/use-prompt"; +import { RichInput } from "./rich-input"; + +beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); + +async function input(value: string) { + let editor: Editor | undefined; + const onChange = vi.fn(); + render( + + + { + editor = event.editor; + }} + /> + + , + ); + await waitFor(() => expect(editor).toBeDefined()); + if (!editor) throw new Error("Editor did not initialize"); + return { editor, onChange }; +} + +const increase = () => fireEvent.click(screen.getByTitle("Increase indent")); +const decrease = () => fireEvent.click(screen.getByTitle("Decrease indent")); +const levels = (editor: Editor) => editor.getJSON().content?.map((node) => node.attrs?.indent ?? 0); + +describe("RichInput paragraph indentation (#3397)", () => { + it("indents the entire paragraph and emits round-trippable HTML", async () => { + const { editor, onChange } = await input("

First

"); + expect(screen.getByTitle("Decrease indent")).toBeDisabled(); + expect(screen.getByTitle("Increase indent")).toBeEnabled(); + increase(); + const html = '

First

'; + expect(editor.getHTML()).toBe(html); + expect(onChange).toHaveBeenLastCalledWith(html); + act(() => { + editor.commands.setContent(html, { emitUpdate: false }); + }); + expect(editor.getHTML()).toBe(html); + decrease(); + expect(editor.getHTML()).toBe("

First

"); + }); + + it.each(["p", "h1", "h2", "h3", "h4", "h5", "h6"])("restores saved %s indentation on initialization", async (tag) => { + const html = `<${tag} data-indent="2" style="margin-inline-start: 48px;">First

Last

`; + const { editor } = await input(html); + expect(editor.getHTML()).toBe(html); + }); + + it("updates mixed levels in one transaction and supports undo/redo", async () => { + const { editor, onChange } = await input('

First

Second

Third

'); + act(() => { + editor.commands.selectAll(); + }); + increase(); + expect(levels(editor)).toEqual([1, 4, 8]); + expect(onChange).toHaveBeenCalledTimes(1); + act(() => { + editor.commands.undo(); + }); + expect(levels(editor)).toEqual([0, 3, 8]); + act(() => { + editor.commands.redo(); + }); + expect(levels(editor)).toEqual([1, 4, 8]); + decrease(); + expect(levels(editor)).toEqual([0, 3, 7]); + }); + + it("preserves each level through paragraph/heading conversions", async () => { + const { editor } = await input('

First

Second

'); + act(() => { + editor.commands.selectAll(); + editor.commands.toggleHeading({ level: 2 }); + }); + expect(levels(editor).slice(0, 2)).toEqual([1, 3]); + act(() => { + editor.commands.setParagraph(); + }); + expect(levels(editor).slice(0, 2)).toEqual([1, 3]); + }); + + it("indents the selected paragraph inside a blockquote", async () => { + const { editor } = await input("

First

Second

"); + act(() => { + editor.commands.setTextSelection(2); + }); + increase(); + expect(editor.getHTML()).toContain( + '

First

Second

', + ); + }); + + it("disables indent at eight without an update", async () => { + const { editor, onChange } = await input('

First

'); + expect(screen.getByTitle("Increase indent")).toBeDisabled(); + increase(); + expect(levels(editor)).toEqual([8]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each(["ul", "ol"])("keeps %s controls as list nesting operations", async (tag) => { + const { editor } = await input(`<${tag}>
  • First

  • Second

  • Last

    `); + act(() => { + editor.commands.setTextSelection(12); + }); + increase(); + expect(editor.getHTML()).toContain(`<${tag}>
  • Second

  • `); + expect(editor.getHTML()).not.toContain("data-indent"); + decrease(); + expect(editor.getHTML()).toBe(`<${tag}>
  • First

  • Second

  • Last

    `); + }); + + it.each(["toggleBulletList", "toggleOrderedList"] as const)( + "normalizes indentation when converting to a list with %s", + async (command) => { + const { editor } = await input('

    First

    '); + act(() => { + if (command === "toggleBulletList") editor.commands.toggleBulletList(); + else editor.commands.toggleOrderedList(); + }); + expect(editor.getHTML()).not.toContain("data-indent"); + act(() => { + editor.commands.undo(); + }); + expect(levels(editor)[0]).toBe(2); + act(() => { + editor.commands.redo(); + if (command === "toggleBulletList") editor.commands.toggleBulletList(); + else editor.commands.toggleOrderedList(); + }); + expect(editor.getHTML()).not.toContain("data-indent"); + expect(editor.getHTML()).not.toContain("
  • "); + }, + ); + + it("clears paragraph offsets when typing the bullet-list shortcut", async () => { + const { editor } = await input('

    First

    '); + act(() => { + editor.commands.setTextSelection(1); + editor.view.someProp("handleTextInput", (handle) => + handle(editor.view, 1, 1, "- ", () => editor.state.tr.insertText("- ", 1)), + ); + }); + expect(editor.getHTML()).toContain("
    • First

    "); + expect(editor.getHTML()).not.toContain("data-indent"); + }); + + it("clears imported paragraph offsets inside lists", async () => { + const { editor } = await input('
    • First

    '); + expect(editor.getHTML()).not.toContain("data-indent"); + expect(editor.getHTML()).not.toContain("margin-inline-start"); + }); + + it("honors explicit indentation when changing a block type", async () => { + const { editor } = await input('

    First

    '); + act(() => { + editor.commands.setNode("heading", { level: 2, indent: 0 }); + }); + expect(editor.getHTML()).toContain("

    First

    "); + }); + + it("characterizes literal leading spaces and tabs in saved HTML", async () => { + const { editor } = await input("

    First

    \tSecond

    "); + expect(editor.getHTML()).toBe("

    First

    Second

    "); + }); + + it("does not mutate during capability checks or beyond either bound", async () => { + const { editor, onChange } = await input("

    First

    "); + expect(editor.can().increaseIndent()).toBe(true); + expect(editor.can().decreaseIndent()).toBe(false); + act(() => { + expect(editor.commands.decreaseIndent()).toBe(false); + }); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + for (let step = 0; step < 8; step++) editor.commands.increaseIndent(); + }); + expect(levels(editor)).toEqual([8]); + expect(editor.can().increaseIndent()).toBe(false); + act(() => { + expect(editor.commands.increaseIndent()).toBe(false); + }); + expect(onChange).toHaveBeenCalledTimes(8); + }); + + it("skips list descendants when a selection starts outside the list", async () => { + const { editor } = await input("

    First

    • Second

    Third

    "); + act(() => { + editor.commands.selectAll(); + }); + increase(); + expect(editor.getHTML()).toBe( + '

    First

    • Second

    Third

    ', + ); + }); + + it("renders logical margins in RTL without physical left/right margins", async () => { + i18n.loadAndActivate({ locale: "he", messages: {} }); + try { + const { editor } = await input('

    First

    '); + const paragraph = editor.view.dom.querySelector("p"); + expect(paragraph?.style.marginInlineStart).toBe("24px"); + expect(paragraph?.style.marginLeft).toBe(""); + expect(paragraph?.style.marginRight).toBe(""); + } finally { + act(() => { + i18n.loadAndActivate({ locale: "en", messages: {} }); + }); + } + }); + + it("characterizes typed leading whitespace as lost on HTML re-import", async () => { + const { editor, onChange } = await input("

    First

    "); + act(() => { + editor.view.dispatch(editor.state.tr.insertText(" \t", 1)); + }); + const saved = editor.getHTML(); + expect(saved).toBe("

    \tFirst

    "); + expect(onChange).toHaveBeenLastCalledWith(saved); + act(() => { + editor.commands.setContent(saved, { emitUpdate: false }); + }); + expect(editor.getHTML()).toBe("

    First

    "); + }); +}); diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index 3fc4ed43e..13f43285e 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -57,6 +57,7 @@ import { cn } from "@reactive-resume/utils/style"; import { usePrompt } from "@/hooks/use-prompt"; import { isRTL } from "@/libs/locale"; import { ColorPicker } from "./color-picker"; +import { ParagraphIndent } from "./paragraph-indent"; import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils"; const defaultTextColor = "rgba(0, 0, 0, 1)"; @@ -86,6 +87,7 @@ const extensions = [ }, }), TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }), + ParagraphIndent, ]; type Props = UseEditorOptions & { @@ -308,13 +310,13 @@ function useEditorToolbarState(editor: Editor) { canOrderedList: ctx.editor.can().chain().toggleOrderedList().run() ?? false, toggleOrderedList: () => ctx.editor.chain().focus().toggleOrderedList().run(), - // Outdent List Item - canLiftListItem: ctx.editor.can().chain().liftListItem("listItem").run() ?? false, - liftListItem: () => ctx.editor.chain().focus().liftListItem("listItem").run(), + // Outdent block or list item + canDecreaseIndent: ctx.editor.can().chain().decreaseIndent().run() ?? false, + decreaseIndent: () => ctx.editor.chain().focus().decreaseIndent().run(), - // Indent List Item - canSinkListItem: ctx.editor.can().chain().sinkListItem("listItem").run() ?? false, - sinkListItem: () => ctx.editor.chain().focus().sinkListItem("listItem").run(), + // Indent block or list item + canIncreaseIndent: ctx.editor.can().chain().increaseIndent().run() ?? false, + increaseIndent: () => ctx.editor.chain().focus().increaseIndent().run(), // Link isLink: ctx.editor.isActive("link") ?? false, @@ -717,8 +719,8 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) { variant="ghost" className="rounded-none" title={t`Decrease indent`} - disabled={!state.canLiftListItem} - onClick={state.liftListItem} + disabled={!state.canDecreaseIndent} + onClick={state.decreaseIndent} > @@ -729,8 +731,8 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) { variant="ghost" className="rounded-none" title={t`Increase indent`} - disabled={!state.canSinkListItem} - onClick={state.sinkListItem} + disabled={!state.canIncreaseIndent} + onClick={state.increaseIndent} > diff --git a/packages/docx/src/builder.ts b/packages/docx/src/builder.ts index e79630032..363227781 100644 --- a/packages/docx/src/builder.ts +++ b/packages/docx/src/builder.ts @@ -15,6 +15,7 @@ import { WidthType, } from "docx"; import { parseColorString } from "@reactive-resume/utils/color"; +import { isRTL } from "@reactive-resume/utils/locale"; import { shouldShowResumeHeader } from "./cover-letter"; import { toSafeDocxLink } from "./link-utils"; import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers"; @@ -456,6 +457,7 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol }, paragraph: { spacing: { line: lineSpacing }, + ...(isRTL(page.locale) ? { bidirectional: true } : {}), }, }, }, diff --git a/packages/docx/src/html-to-docx.ts b/packages/docx/src/html-to-docx.ts index d26cdf6c8..d25399d7b 100644 --- a/packages/docx/src/html-to-docx.ts +++ b/packages/docx/src/html-to-docx.ts @@ -1,4 +1,4 @@ -import type { IShadingAttributesProperties, ISpacingProperties } from "docx"; +import type { IShadingAttributesProperties } from "docx"; import { ExternalHyperlink, HeadingLevel, Paragraph, TextRun } from "docx"; import { isDarkColor, parseColorString } from "@reactive-resume/utils/color"; import { toSafeDocxLink } from "./link-utils"; @@ -122,14 +122,27 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] { return children; } -function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Paragraph[], listLevel?: number): void { +function processBlockElement( + el: HTMLElement, + style: InlineStyle, + paragraphs: Paragraph[], + listLevel?: number, + quoteIndent = 0, +): void { const tag = el.tagName; const mergedStyle = mergeStyle(style, tag, el); + const level = Number(el.getAttribute("data-indent")); + // 24 CSS px = 18 pt = 360 twips. Lists retain their existing numbering indentation. + const paragraphIndent = listLevel == null && Number.isInteger(level) && level > 0 && level <= 8 ? level * 360 : 0; + const start = quoteIndent + paragraphIndent; + const indent = start ? { start } : undefined; if (HEADING_MAP[tag]) { const inlineChildren = collectInlineChildren(el, mergedStyle); if (inlineChildren.length > 0) { - paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren })); + paragraphs.push( + new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren, ...(indent ? { indent } : {}) }), + ); } return; } @@ -137,7 +150,12 @@ function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Pa if (tag === "P" || tag === "DIV") { const inlineChildren = collectInlineChildren(el, mergedStyle); if (inlineChildren.length > 0) { - paragraphs.push(new Paragraph({ children: inlineChildren })); + paragraphs.push( + new Paragraph({ + children: inlineChildren, + ...(tag === "P" && indent ? { indent } : quoteIndent ? { indent: { start: quoteIndent } } : {}), + }), + ); } return; } @@ -145,6 +163,7 @@ function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Pa if (tag === "UL" || tag === "OL") { // ponytail: ordered-list numbering (numberingRef path) was never reachable; always uses bullet const level = listLevel != null ? listLevel + 1 : 0; + const listIndent = quoteIndent ? { start: (level + 1) * 720 + quoteIndent } : undefined; for (const li of el.children) { if (li.tagName !== "LI") continue; @@ -158,16 +177,28 @@ function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Pa if (liChild.nodeType === Node.TEXT_NODE) { const text = (liChild.textContent ?? "").trim(); if (text) { - paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...mergedStyle })], bullet: { level } })); + paragraphs.push( + new Paragraph({ + children: [new TextRun({ text, ...mergedStyle })], + bullet: { level }, + ...(listIndent ? { indent: listIndent } : {}), + }), + ); } } else if (liChild.nodeType === Node.ELEMENT_NODE) { - processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level); + processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level, quoteIndent); } } } else { const inlineChildren = collectInlineChildren(li, mergedStyle); if (inlineChildren.length > 0) { - paragraphs.push(new Paragraph({ children: inlineChildren, bullet: { level } })); + paragraphs.push( + new Paragraph({ + children: inlineChildren, + bullet: { level }, + ...(listIndent ? { indent: listIndent } : {}), + }), + ); } } } @@ -175,17 +206,28 @@ function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Pa } if (tag === "BLOCKQUOTE") { - const indent: ISpacingProperties = {}; - const inlineChildren = collectInlineChildren(el, { ...mergedStyle, italics: true }); - if (inlineChildren.length > 0) { - paragraphs.push( - new Paragraph({ - children: inlineChildren, - indent: { left: 720 }, - spacing: indent, - }), - ); + const quoteStyle = { ...mergedStyle, italics: true }; + const inline = el.ownerDocument.createElement("p"); + const flushInline = () => { + if (!inline.hasChildNodes()) return; + processBlockElement(inline, quoteStyle, paragraphs, listLevel, quoteIndent + 720); + inline.replaceChildren(); + }; + // Keep each semantic paragraph's own offset and preserve adjacent inline + // content as one paragraph. Nested quotes add their existing 720-twip inset. + for (const child of el.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as HTMLElement; + if (HEADING_MAP[element.tagName] || /^(P|DIV|UL|OL|BLOCKQUOTE|PRE|HR)$/.test(element.tagName)) { + flushInline(); + processBlockElement(element, quoteStyle, paragraphs, listLevel, quoteIndent + 720); + continue; + } + } + if (child.nodeType === Node.TEXT_NODE && !child.textContent?.trim() && !inline.hasChildNodes()) continue; + inline.appendChild(child.cloneNode(true)); } + flushInline(); return; } @@ -195,6 +237,7 @@ function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Pa paragraphs.push( new Paragraph({ children: [new TextRun({ text, font: "Courier New", ...mergedStyle })], + ...(quoteIndent ? { indent: { start: quoteIndent } } : {}), }), ); } diff --git a/packages/docx/src/paragraph-indent.test.ts b/packages/docx/src/paragraph-indent.test.ts new file mode 100644 index 000000000..7b3d03002 --- /dev/null +++ b/packages/docx/src/paragraph-indent.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { Document } from "docx"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { buildDocument } from "./builder"; +import { htmlToParagraphs } from "./html-to-docx"; + +function paragraphXml(html: string) { + const paragraphs = htmlToParagraphs(html); + const file = new Document({ sections: [{ children: paragraphs }] }); + return JSON.stringify( + paragraphs.map((paragraph) => paragraph.prepForXml({ file, viewWrapper: file.Document, stack: [] })), + ); +} + +describe("DOCX paragraph indentation (#3397)", () => { + it.each(["p", "h1", "h2", "h3", "h4", "h5", "h6"])("maps %s indentation to logical-start twips", (tag) => { + const xml = paragraphXml(`<${tag} data-indent="2">First`); + expect(xml).toContain('"w:start":720'); + }); + + it("keeps unindented and list structure unchanged", () => { + for (const [plain, marked] of [ + ["

    First

    ", '

    First

    '], + ["
    • First

    ", '
    • First

    '], + ["
    1. First
    ", '
    1. First
    '], + ] as const) + expect(paragraphXml(marked)).toBe(paragraphXml(plain)); + }); + + it("adds paragraph indentation to the quote inset without flattening paragraphs", () => { + const html = '

    First

    Second

    '; + expect(htmlToParagraphs(html)).toHaveLength(2); + const xml = paragraphXml(html); + expect(xml).toContain('"w:start":1080'); + expect(xml).toContain('"w:start":1440'); + expect(xml).toContain('"w:b"'); + expect(xml).toContain('"w:i"'); + }); + + it("preserves the quote inset on code blocks without indenting ordinary code blocks", () => { + const quoted = paragraphXml("
    First
    "); + expect(quoted).toContain('"w:start":720'); + expect(quoted).toContain("Courier New"); + expect(paragraphXml("
    First
    ")).not.toContain('"w:ind"'); + }); + + it.each(["ul", "ol"])("adds the quote inset to direct %s list items", (tag) => { + const xml = paragraphXml(`
    <${tag}>
  • First
  • `); + expect(xml).toContain('"w:start":1440'); + }); + + it.each(["en-US", "he-IL", "ar-SA"])( + "uses the resume locale for logical indentation in %s DOCX documents", + (locale) => { + const data = structuredClone(defaultResumeData); + data.metadata.page.locale = locale; + data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }]; + data.summary.content = '

    IndentProbe

    '; + const file = buildDocument(data); + const context = { file, viewWrapper: file.Document, stack: [] }; + const defaults = JSON.stringify(file.Styles.prepForXml(context)); + const document = JSON.stringify(file.Document.View.prepForXml(context)); + expect(document).toContain('"w:start":720'); + if (locale === "en-US") expect(defaults).not.toContain('"w:bidi"'); + else expect(defaults).toContain('"w:bidi"'); + }, + ); + + it("characterizes spaces and literal tabs as preserved text, not paragraph indentation", () => { + const json = paragraphXml("

    First

    \tSecond

    "); + expect(json).toContain(" First"); + expect(json).toContain("\\tSecond"); + expect(json).not.toContain('"w:ind"'); + }); +}); diff --git a/packages/pdf/src/paragraph-indent.integration.test.tsx b/packages/pdf/src/paragraph-indent.integration.test.tsx new file mode 100644 index 000000000..b9aebafa1 --- /dev/null +++ b/packages/pdf/src/paragraph-indent.integration.test.tsx @@ -0,0 +1,207 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { act } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { createResumePdfFile } from "./server"; + +const require = createRequire(import.meta.url); +const standardFontDataUrl = `${join(dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts")}/`; + +async function readParagraphs(content: string, locale = "en-US", sidebarWidth?: number) { + const data = structuredClone(defaultResumeData); + data.picture.hidden = true; + data.basics.name = "Jane Doe"; + data.metadata.template = "onyx"; + data.metadata.page.locale = locale; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }]; + data.summary.content = content; + if (sidebarWidth) { + data.metadata.template = "chikorita"; + data.metadata.layout.sidebarWidth = sidebarWidth; + data.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: ["summary"] }]; + } + let file: File | undefined; + await act(async () => { + file = await createResumePdfFile({ data, filename: "indent.pdf" }); + }); + if (!file) throw new Error("PDF generation failed"); + const task = getDocument({ data: new Uint8Array(await file.arrayBuffer()), standardFontDataUrl }); + try { + const document = await task.promise; + const pageTexts = await Promise.all( + Array.from({ length: document.numPages }, async (_, index) => { + const page = await document.getPage(index + 1); + return { page: index + 1, text: await page.getTextContent() }; + }), + ); + return { + pages: document.numPages, + items: pageTexts.flatMap(({ page, text }) => + text.items.flatMap((item) => + "str" in item + ? [{ page, text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }] + : [], + ), + ), + }; + } finally { + await task.destroy(); + } +} + +describe("actual PDF paragraph indentation (#3397)", () => { + it.each(["en-US", "he-IL"])("moves paragraphs and headings from logical start in %s", async (locale) => { + for (const tag of ["p", "h2"]) { + // Heading alignment is independently configurable; exercise each logical edge. + const align = locale === "he-IL" ? "right" : "left"; + const plain = await readParagraphs(`<${tag} style="text-align: ${align};">First`, locale); + const indented = await readParagraphs( + `<${tag} data-indent="2" style="text-align: ${align}; margin-inline-start: 48px;">First`, + locale, + ); + const baseline = plain.items.find((item) => item.text === "First"); + const moved = indented.items.find((item) => item.text === "First"); + expect(baseline).toBeDefined(); + expect(moved).toBeDefined(); + if (!baseline || !moved) throw new Error("Expected paragraph text in PDF"); + expect(moved.x - baseline.x).toBeCloseTo(locale === "en-US" ? 36 : -36, 2); + expect(moved.y).toBeCloseTo(baseline.y, 2); + expect(indented.pages).toBe(plain.pages); + } + }); + + it.each(["en-US", "he-IL"])("preserves unindented and nested-list output in %s", async (locale) => { + const plain = "

    First

    • Second

      1. Third
    "; + const marked = + '

    First

    • Second

      1. Third
    '; + expect(await readParagraphs(marked, locale)).toEqual(await readParagraphs(plain, locale)); + }); + + it.each(["en-US", "he-IL"])("preserves the quote inset while indenting one paragraph in %s", async (locale) => { + const plain = await readParagraphs("

    First

    Second

    ", locale); + const indented = await readParagraphs('

    First

    Second

    ', locale); + for (const text of ["First", "Second"]) { + const baseline = plain.items.find((item) => item.text === text); + const moved = indented.items.find((item) => item.text === text); + if (!baseline || !moved) throw new Error(`Expected ${text} in PDF`); + expect(moved.x - baseline.x).toBeCloseTo(text === "Second" ? 0 : locale === "en-US" ? 36 : -36, 2); + } + }); + + it.each([ + ["p", "en-US", 25], + ["p", "he-IL", 25], + ["h2", "en-US", 25], + ["h2", "he-IL", 25], + ["p", "en-US", 35], + ["p", "he-IL", 35], + ["blockquote", "en-US", 25], + ["blockquote", "he-IL", 25], + ] as const)( + "preserves %s text at maximum indentation in a %s narrow sidebar (sidebar %s)", + async (tag, locale, width) => { + const sample = + tag === "h2" + ? "START Some text fits each line END" + : "TARGET Some plain readable words continue through narrow columns without disappearing END"; + const html = + tag === "blockquote" + ? `

    ${sample}

    ` + : `<${tag} data-indent="8">${sample}`; + const rendered = await readParagraphs(html, locale, width); + const text = rendered.items + .map((item) => item.text) + .join("") + .replace(/[-\s]/g, ""); + expect(text).toContain(sample.replace(/\s/g, "")); + for (const item of rendered.items) { + expect(item.x).toBeGreaterThanOrEqual(0); + expect(item.x + item.width).toBeLessThanOrEqual(595.38); + } + }, + ); + + it("retains the existing PDF limitation for words wider than their available line", async () => { + const content = "TARGET Some plain readable words continue through narrow columns without disappearing END"; + const indented = await readParagraphs(`

    ${content}

    `, "en-US", 25); + // This control takes the existing renderer path, with the same remaining + // width as the bounded indent. Neither path introduces forced word breaks. + const reducedWidth = await readParagraphs(`

    ${content}

    `, "en-US", 25); + expect(indented).toEqual(reducedWidth); + expect(indented.items.map((item) => item.text).join(" ")).not.toContain("disappearing"); + }); + + it("keeps indented RTL pseudo-bullets inside narrow sidebars", async () => { + const rendered = await readParagraphs('

    - First
    - Second

    ', "he-IL", 25); + expect(rendered.items.map((item) => item.text).join(" ")).toContain("First"); + expect(rendered.items.map((item) => item.text).join(" ")).toContain("Second"); + for (const item of rendered.items) { + expect(item.x).toBeGreaterThanOrEqual(0); + expect(item.x + item.width).toBeLessThanOrEqual(595.38); + } + }); + + it("moves every wrapped line, not only the first line", async () => { + const content = "Wrapped paragraph text stays within its own block. ".repeat(18); + const plain = await readParagraphs(`

    ${content}

    `); + const indented = await readParagraphs(`

    ${content}

    `); + const baseline = plain.items.find((item) => item.text.includes("Wrapped")); + const lines = indented.items.filter((item) => item.text.includes("Wrapped")); + if (!baseline) throw new Error("Expected paragraph text in PDF"); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) expect(line.x - baseline.x).toBeCloseTo(36, 2); + }); + + it.each(["en-US", "he-IL"])("retains indentation and text across physical pages in %s", async (locale) => { + const content = "Wrapped text stays visible. ".repeat(600); + const plain = await readParagraphs( + `

    ${content}

    `, + locale, + ); + const indented = await readParagraphs(`

    ${content}

    `, locale); + expect(indented.pages).toBeGreaterThan(1); + const lines = indented.items.filter((item) => item.text.includes("Wrapped")); + expect( + lines + .map((item) => item.text) + .join(" ") + .match(/Wrapped/g), + ).toHaveLength(600); + for (let page = 1; page <= indented.pages; page++) { + const moved = lines.find((item) => item.page === page); + const baseline = plain.items.find((item) => item.page === page && item.text.includes("Wrapped")); + if (!moved || !baseline) throw new Error(`Missing paragraph on page ${page}`); + const edge = (item: typeof moved) => item.x + (locale === "he-IL" ? item.width : 0); + // Compare with the original margin-based Text at the same line width. + expect(edge(moved)).toBeCloseTo(edge(baseline), 2); + } + }); + + it("ignores paragraph offsets in RTL list descendants with pseudo-bullets", async () => { + const plain = "
    • - First
      - Second

      Third

    "; + const marked = '
    • - First
      - Second

      Third

    '; + expect(await readParagraphs(marked, "he-IL")).toEqual(await readParagraphs(plain, "he-IL")); + }); + + it("characterizes leading spaces and tabs as collapsed by PDF HTML rendering", async () => { + expect(await readParagraphs("

    First

    \tSecond

    ")).toEqual( + await readParagraphs("

    First

    Second

    "), + ); + }); + + it("indents every line of RTL pseudo-bullet paragraphs", async () => { + const plain = await readParagraphs("

    - First
    - Second

    ", "he-IL"); + const indented = await readParagraphs('

    - First
    - Second

    ', "he-IL"); + for (const text of ["First", "Second"]) { + const baseline = plain.items.find((item) => item.text.includes(text)); + const moved = indented.items.find((item) => item.text.includes(text)); + if (!baseline || !moved) throw new Error(`Expected ${text} in PDF`); + expect(moved.x - baseline.x).toBeCloseTo(-36, 2); + expect(moved.y).toBeCloseTo(baseline.y, 2); + } + }); +}); diff --git a/packages/pdf/src/templates/shared/rich-text-html.ts b/packages/pdf/src/templates/shared/rich-text-html.ts index 7891faa67..2083edaf0 100644 --- a/packages/pdf/src/templates/shared/rich-text-html.ts +++ b/packages/pdf/src/templates/shared/rich-text-html.ts @@ -106,6 +106,24 @@ const unwrapSingleParagraphListItems = (root: ReturnType) => { } }; +const normalizeParagraphIndentation = (root: ReturnType, direction: "ltr" | "rtl") => { + for (const element of root.querySelectorAll("p,h1,h2,h3,h4,h5,h6")) { + if (!element.hasAttribute("data-indent")) continue; + // react-pdf-html does not support CSS logical margins. Convert only the editor's + // explicit indentation contract to PDF points, keeping semantic ancestry intact. + const style = (element.getAttribute("style") ?? "").replace(/(?:^|;)\s*margin-inline-start\s*:[^;]*(?:;|$)/gi, ";"); + const level = Number(element.getAttribute("data-indent")); + const insideList = element.closest("li") !== null; + const indent = + !insideList && Number.isInteger(level) && level > 0 && level <= 8 + ? `margin-${direction === "rtl" ? "right" : "left"}: ${level * 18}pt` + : ""; + const nextStyle = [style, indent].filter(Boolean).join(";"); + if (nextStyle) element.setAttribute("style", nextStyle); + else element.removeAttribute("style"); + } +}; + const isInlineNode = (node: Node): boolean => { if (node.nodeType === NodeType.TEXT_NODE || node.nodeType === NodeType.COMMENT_NODE) return true; if (node.nodeType !== NodeType.ELEMENT_NODE) return false; @@ -142,10 +160,17 @@ const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | n return `
      ${items.map((item) => `
    • ${item}
    • `).join("")}
    `; }; -export const convertPseudoBulletParagraphs = (html: string): string => +export const convertPseudoBulletParagraphs = (html: string, direction: "ltr" | "rtl" = "ltr"): string => html.replace(/]*)>([\s\S]*?)<\/p>/gi, (full, _attrs, inner) => { const converted = tryConvertPseudoBulletParagraph(inner); - return converted ?? full; + if (!converted) return full; + const level = Number(parse(full).querySelector("p")?.getAttribute("data-indent")); + if (!Number.isInteger(level) || level <= 0 || level > 8) return converted; + // Keep the original paragraph's offset around the entire generated list. + return converted.replace( + "
      ", + `
        `, + ); }); const decodeSoftHyphens = (node: Node): void => { @@ -171,6 +196,7 @@ export const normalizeRichTextHtml = ( if (softHyphens) decodeSoftHyphens(root); normalizeBoldBoundaryWhitespace(root); normalizeMarkElements(root); + normalizeParagraphIndentation(root, direction); unwrapSingleParagraphListItems(root); const flushInlineNodes = () => { @@ -201,7 +227,7 @@ export const normalizeRichTextHtml = ( // RTL pseudo-bullets must become real list items before both the semantic // descriptor and renderer traverse the HTML. RLM anchors each independent // react-pdf-html text frame without changing element ancestry or indices. - return convertPseudoBulletParagraphs(normalizedHtml).replace( + return convertPseudoBulletParagraphs(normalizedHtml, direction).replace( /<(p|li)\b([^>]*)>/gi, (_match, tag, rest) => `<${tag}${rest}>‏`, ); diff --git a/packages/pdf/src/templates/shared/rich-text-renderers.ts b/packages/pdf/src/templates/shared/rich-text-renderers.ts index 83d5f63ce..12b0de834 100644 --- a/packages/pdf/src/templates/shared/rich-text-renderers.ts +++ b/packages/pdf/src/templates/shared/rich-text-renderers.ts @@ -1,14 +1,14 @@ import type { Style } from "@react-pdf/types"; -import type { ReactNode } from "react"; -import { createElement } from "react"; -import { Text as PdfText } from "#react-pdf-renderer"; +import type { ReactElement, ReactNode } from "react"; +import { cloneElement, createElement } from "react"; +import { Text as PdfText, View } from "#react-pdf-renderer"; import { getRichTextEdgeTrimStyle, isRichTextElementInsideListItem, stripRichTextVerticalMargins, } from "./rich-text-spacing"; import { safeTextStyle } from "./safe-text-style"; -import { composeStyles } from "./styles"; +import { composeStyles, mergeStyles } from "./styles"; export const toRichTextStyleArray = (style: Style | Style[] | undefined): Style[] => { if (!style) return []; @@ -23,6 +23,7 @@ type RichTextParagraphRendererProps = { style: Style | Style[] | undefined; semanticStyle?: Style | Style[] | undefined; rtl?: boolean; + indent?: number; rtlTextWrapStyle?: Style | undefined; applyRtlDirection?: (node: ReactNode) => ReactNode; textProps?: Record; @@ -34,6 +35,7 @@ export const renderRichTextParagraph = ({ semanticStyle, children, rtl, + indent, rtlTextWrapStyle, applyRtlDirection, textProps, @@ -52,5 +54,26 @@ export const renderRichTextParagraph = ({ const content = rtl && applyRtlDirection ? applyRtlDirection(children) : children; - return createElement(PdfText, { ...textProps, style: composedStyle }, content); + // Renderer Text also accepts SVG props; this instance uses paragraph styles. + const paragraph = createElement(PdfText, { ...textProps, style: composedStyle }, content) as ReactElement<{ + style: Style[]; + }>; + return indent && !isRichTextElementInsideListItem(element) ? renderWithBoundedIndent(paragraph, rtl) : paragraph; +}; + +/** Keep at least half the available width for text, even inside narrow sidebars. + * Flex resolves the inset against its actual parent rather than the page width. */ +export const renderWithBoundedIndent = (node: ReactElement<{ style?: Style | Style[] | undefined }>, rtl = false) => { + const side = rtl ? "marginRight" : "marginLeft"; + const rawOffset = mergeStyles(node.props.style)[side]; + const offset = + typeof rawOffset === "string" && /^\d+(?:\.\d+)?pt$/.test(rawOffset) ? Number.parseFloat(rawOffset) : rawOffset; + if (typeof offset !== "number" || !Number.isFinite(offset) || offset <= 0) return node; + + return createElement( + View, + { style: { flexDirection: rtl ? "row-reverse" : "row", alignSelf: "stretch" } }, + createElement(View, { style: { width: offset, maxWidth: "50%" } }), + cloneElement(node, { style: composeStyles(node.props.style, { [side]: 0, flexBasis: 0, flexGrow: 1 }) }), + ); }; diff --git a/packages/pdf/src/templates/shared/rich-text.tsx b/packages/pdf/src/templates/shared/rich-text.tsx index e423de51b..7e0166e02 100644 --- a/packages/pdf/src/templates/shared/rich-text.tsx +++ b/packages/pdf/src/templates/shared/rich-text.tsx @@ -21,7 +21,7 @@ import { richTextMarkClassName, richTextSemanticNodeKeyAttribute, } from "./rich-text-html"; -import { renderRichTextParagraph, toRichTextStyleArray } from "./rich-text-renderers"; +import { renderRichTextParagraph, renderWithBoundedIndent, toRichTextStyleArray } from "./rich-text-renderers"; import { createRichTextProseSpacing, getRichTextEdgeTrimStyle, @@ -122,6 +122,7 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { element.getAttribute(richTextSemanticNodeKeyAttribute) ?? (richTextNodeKey ? getRichTextSemanticNodeKey(richTextNodeKey, element, richTextMarkClassName) : undefined); const resolvedFor = (element: Parameters[1]) => resolveNode(keyFor(element)); + const renderText = ({ element, style, @@ -135,11 +136,14 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { const resolved = resolveNode(nodeKey); const visible = isNodeVisible(nodeKey); if (!visible) return null; - return ( + const text = ( {textChildren} ); + return /^h[1-6]$/i.test(element.rawTagName) && Number(element.getAttribute("data-indent")) > 0 + ? renderWithBoundedIndent(text, rtl) + : text; }; const renderView = ({ element, @@ -154,11 +158,12 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { const resolved = resolveNode(nodeKey); const visible = isNodeVisible(nodeKey); if (!visible) return null; - return ( + const view = ( {viewChildren} ); + return Number(element.getAttribute("data-paragraph-indent")) > 0 ? renderWithBoundedIndent(view, rtl) : view; }; return ( @@ -223,6 +228,7 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { const paragraphProps = { ...props, style: props.style, + indent: Number(props.element.getAttribute("data-indent")), semanticStyle: resolved.style, textProps: { ...resolvedPdfTextProps(resolved), hyphenationCallback }, rtl,