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
This commit is contained in:
Amruth Pillai
2026-09-05 10:41:30 -07:00
committed by GitHub
parent 5c8338c175
commit f468651c79
10 changed files with 777 additions and 38 deletions
@@ -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<ReturnType> {
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;
},
};
},
});
@@ -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(
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<RichInput
value={value}
onChange={onChange}
onCreate={(event) => {
editor = event.editor;
}}
/>
</PromptDialogProvider>
</I18nProvider>,
);
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("<p>First</p>");
expect(screen.getByTitle("Decrease indent")).toBeDisabled();
expect(screen.getByTitle("Increase indent")).toBeEnabled();
increase();
const html = '<p data-indent="1" style="margin-inline-start: 24px;">First</p>';
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("<p>First</p>");
});
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</${tag}><p>Last</p>`;
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('<p>First</p><h2 data-indent="3">Second</h2><p data-indent="8">Third</p>');
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('<p data-indent="1">First</p><p data-indent="3">Second</p>');
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("<blockquote><p>First</p><p>Second</p></blockquote>");
act(() => {
editor.commands.setTextSelection(2);
});
increase();
expect(editor.getHTML()).toContain(
'<blockquote><p data-indent="1" style="margin-inline-start: 24px;">First</p><p>Second</p></blockquote>',
);
});
it("disables indent at eight without an update", async () => {
const { editor, onChange } = await input('<p data-indent="8">First</p>');
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}><li><p>First</p></li><li><p>Second</p></li></${tag}><p>Last</p>`);
act(() => {
editor.commands.setTextSelection(12);
});
increase();
expect(editor.getHTML()).toContain(`<${tag}><li><p>Second</p></li></${tag}>`);
expect(editor.getHTML()).not.toContain("data-indent");
decrease();
expect(editor.getHTML()).toBe(`<${tag}><li><p>First</p></li><li><p>Second</p></li></${tag}><p>Last</p>`);
});
it.each(["toggleBulletList", "toggleOrderedList"] as const)(
"normalizes indentation when converting to a list with %s",
async (command) => {
const { editor } = await input('<p data-indent="2">First</p>');
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("<li>");
},
);
it("clears paragraph offsets when typing the bullet-list shortcut", async () => {
const { editor } = await input('<p data-indent="2">First</p>');
act(() => {
editor.commands.setTextSelection(1);
editor.view.someProp("handleTextInput", (handle) =>
handle(editor.view, 1, 1, "- ", () => editor.state.tr.insertText("- ", 1)),
);
});
expect(editor.getHTML()).toContain("<ul><li><p>First</p></li></ul>");
expect(editor.getHTML()).not.toContain("data-indent");
});
it("clears imported paragraph offsets inside lists", async () => {
const { editor } = await input('<ul><li><p data-indent="2" style="margin-inline-start: 48px">First</p></li></ul>');
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('<p data-indent="2">First</p>');
act(() => {
editor.commands.setNode("heading", { level: 2, indent: 0 });
});
expect(editor.getHTML()).toContain("<h2>First</h2>");
});
it("characterizes literal leading spaces and tabs in saved HTML", async () => {
const { editor } = await input("<p> First</p><p>\tSecond</p>");
expect(editor.getHTML()).toBe("<p>First</p><p>Second</p>");
});
it("does not mutate during capability checks or beyond either bound", async () => {
const { editor, onChange } = await input("<p>First</p>");
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("<p>First</p><ul><li><p>Second</p></li></ul><p>Third</p>");
act(() => {
editor.commands.selectAll();
});
increase();
expect(editor.getHTML()).toBe(
'<p data-indent="1" style="margin-inline-start: 24px;">First</p><ul><li><p>Second</p></li></ul><p data-indent="1" style="margin-inline-start: 24px;">Third</p>',
);
});
it("renders logical margins in RTL without physical left/right margins", async () => {
i18n.loadAndActivate({ locale: "he", messages: {} });
try {
const { editor } = await input('<p data-indent="1">First</p>');
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("<p>First</p>");
act(() => {
editor.view.dispatch(editor.state.tr.insertText(" \t", 1));
});
const saved = editor.getHTML();
expect(saved).toBe("<p> \tFirst</p>");
expect(onChange).toHaveBeenLastCalledWith(saved);
act(() => {
editor.commands.setContent(saved, { emitUpdate: false });
});
expect(editor.getHTML()).toBe("<p>First</p>");
});
});
+12 -10
View File
@@ -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}
>
<TextOutdentIcon className="size-3.5" />
</Button>
@@ -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}
>
<TextIndentIcon className="size-3.5" />
</Button>
+2
View File
@@ -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 } : {}),
},
},
},
+60 -17
View File
@@ -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 } } : {}),
}),
);
}
@@ -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</${tag}>`);
expect(xml).toContain('"w:start":720');
});
it("keeps unindented and list structure unchanged", () => {
for (const [plain, marked] of [
["<p>First</p>", '<p data-indent="0">First</p>'],
["<ul><li><p>First</p></li></ul>", '<ul><li><p data-indent="2">First</p></li></ul>'],
["<ol><li>First</li></ol>", '<ol><li data-indent="2">First</li></ol>'],
] as const)
expect(paragraphXml(marked)).toBe(paragraphXml(plain));
});
it("adds paragraph indentation to the quote inset without flattening paragraphs", () => {
const html = '<blockquote><p data-indent="1"><strong>First</strong></p><p data-indent="2">Second</p></blockquote>';
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("<blockquote><pre><code>First</code></pre></blockquote>");
expect(quoted).toContain('"w:start":720');
expect(quoted).toContain("Courier New");
expect(paragraphXml("<pre><code>First</code></pre>")).not.toContain('"w:ind"');
});
it.each(["ul", "ol"])("adds the quote inset to direct %s list items", (tag) => {
const xml = paragraphXml(`<blockquote><${tag}><li>First</li></${tag}></blockquote>`);
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 = '<p data-indent="2">IndentProbe</p>';
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("<p> First</p><p>\tSecond</p>");
expect(json).toContain(" First");
expect(json).toContain("\\tSecond");
expect(json).not.toContain('"w:ind"');
});
});
@@ -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</${tag}>`, locale);
const indented = await readParagraphs(
`<${tag} data-indent="2" style="text-align: ${align}; margin-inline-start: 48px;">First</${tag}>`,
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 = "<p>First</p><ul><li><p>Second</p><ol><li>Third</li></ol></li></ul>";
const marked =
'<p data-indent="0">First</p><ul><li><p data-indent="2" style="margin-inline-start: 48px;">Second</p><ol><li>Third</li></ol></li></ul>';
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("<blockquote><p>First</p><p>Second</p></blockquote>", locale);
const indented = await readParagraphs('<blockquote><p data-indent="2">First</p><p>Second</p></blockquote>', 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"
? `<blockquote><p data-indent="8">${sample}</p></blockquote>`
: `<${tag} data-indent="8">${sample}</${tag}>`;
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(`<h2 data-indent="8">${content}</h2>`, "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(`<h2 style="margin-left: 50%;">${content}</h2>`, "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('<p data-indent="8">- First<br>- Second</p>', "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(`<p>${content}</p>`);
const indented = await readParagraphs(`<p data-indent="2">${content}</p>`);
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(
`<p style="margin-${locale === "he-IL" ? "right" : "left"}: 36pt;">${content}</p>`,
locale,
);
const indented = await readParagraphs(`<p data-indent="2">${content}</p>`, 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 = "<ul><li><p>- First<br>- Second</p><p>Third</p></li></ul>";
const marked = '<ul><li><p data-indent="2">- First<br>- Second</p><p>Third</p></li></ul>';
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("<p> First</p><p>\tSecond</p>")).toEqual(
await readParagraphs("<p>First</p><p>Second</p>"),
);
});
it("indents every line of RTL pseudo-bullet paragraphs", async () => {
const plain = await readParagraphs("<p>- First<br>- Second</p>", "he-IL");
const indented = await readParagraphs('<p data-indent="2">- First<br>- Second</p>', "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);
}
});
});
@@ -106,6 +106,24 @@ const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
}
};
const normalizeParagraphIndentation = (root: ReturnType<typeof parse>, 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 `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
};
export const convertPseudoBulletParagraphs = (html: string): string =>
export const convertPseudoBulletParagraphs = (html: string, direction: "ltr" | "rtl" = "ltr"): string =>
html.replace(/<p\b([^>]*)>([\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(
"<ul>",
`<ul data-paragraph-indent="${level}" style="margin-${direction === "rtl" ? "right" : "left"}: ${level * 18}pt">`,
);
});
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}>‏`,
);
@@ -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<string, unknown>;
@@ -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 }) }),
);
};
@@ -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<typeof getRichTextSemanticNodeKey>[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 = (
<PdfText {...resolvedPdfTextProps(resolved)} style={composeStyles(style, resolved.style, safeTextStyle)}>
{textChildren}
</PdfText>
);
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 = (
<View {...resolvedPdfFlowProps(resolved)} style={composeStyles(style, resolved.style)}>
{viewChildren}
</View>
);
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,