mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
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:
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user