Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e21ca69
fix(core): weigh space compression against expansion in justified lines
jedrazb Oct 14, 2026
f3316fb
fix(core): let a hyphen-break fragment borrow space in justified lines
jedrazb Oct 14, 2026
c78c545
fix(core): let currency and math symbol words lend justified space
jedrazb Oct 14, 2026
0f647c0
fix(docx-to-pdf): scale paint work budgets with the page count
jedrazb Oct 14, 2026
abcd778
fix(core): a note reference on a paragraph's last line needs its spac…
jedrazb Oct 14, 2026
d7081f9
fix(core): fit a paragraph's space after above a footnote area
jedrazb Oct 14, 2026
29d3be3
test(core): cover justified space shrink and footnote space-after fit
jedrazb Oct 14, 2026
9435b4f
fix(docx-to-pdf): find installed Garamond faces
jedrazb Oct 14, 2026
bc509b5
fix(core): read note citations from pending lines by their spans
jedrazb Oct 14, 2026
09bf802
test(docx-to-pdf): cover Garamond discovery; add changeset
jedrazb Oct 14, 2026
bb32385
fix(core): a table row that ends the page needs room for its closing …
jedrazb Oct 14, 2026
7690f2b
refactor(core): pass a table row placement as one value
jedrazb Oct 14, 2026
6d4d846
chore: describe table border fit in the changeset
jedrazb Oct 14, 2026
05fc6c7
fix(core): keep an automatic-width table's outer rules inside the tex…
jedrazb Oct 14, 2026
80562e4
fix(core): let every trailing space hang on the shorter justified line
jedrazb Oct 14, 2026
7f1ba4a
fix(core): size autofit columns for deleted content in every view
jedrazb Oct 14, 2026
e8a4b95
fix(core): let a cross-reference result complete a justified word
jedrazb Oct 14, 2026
a034fec
fix(core): keep the view's own column minimums beside deleted content
jedrazb Oct 14, 2026
853ee18
chore: use neutral examples in comments and tests
jedrazb Oct 15, 2026
92d5e7e
fix(docx-to-pdf): shape installed CJK font collections up to 40 MiB
jedrazb Oct 15, 2026
0dbb5cb
chore: mention CJK fonts in the changeset
jedrazb Oct 15, 2026
daca75f
refactor: address review of justified, footnote, and table fit changes
jedrazb Oct 15, 2026
e48166b
fix(core): keep page and note references out of the justified shrink
jedrazb Oct 15, 2026
31d1101
test(core): a splittable next row keeps a row above a thick bottom rule
jedrazb Oct 15, 2026
dfc835e
fix(core): return lines ahead of a held footnote reference and give S…
jedrazb Oct 15, 2026
53b7744
Merge remote-tracking branch 'origin/main' into fix/justified-line-fi…
jedrazb Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/justified-lines-footnote-fit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@docx-editor.dev/core': minor
'@docx-editor.dev/docx-to-pdf': patch
---

Long documents with justified text, footnotes, tables, and SimSun text now break lines and pages more accurately. PDF export now completes long documents and uses installed Garamond and large CJK fonts.
2 changes: 2 additions & 0 deletions docs/api/docx-editor-core/layout.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,7 @@ export function exportSourceRangeOf(span: StyleSpanRecord): SourceRange | null;

// @public
export interface FieldAtomMarker {
readonly fixedResult?: true;
readonly formControl?: {
readonly accessibleName?: string;
readonly checked: boolean;
Expand Down Expand Up @@ -3931,6 +3932,7 @@ export interface SemanticTableRow {
// @public
export interface SemanticTableStructure {
readonly alignment: TableAlignment;
readonly autoOuterRulePt?: number;
readonly bidiVisual?: true;
readonly cellSpacingPt: number;
// (undocumented)
Expand Down
3 changes: 2 additions & 1 deletion docs/api/docx-editor-docx-to-pdf/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,13 @@ export class PdfEncodingError extends Error {
}

// @public
export interface PdfExportOptions extends Omit<OpenFontBackedDocumentForExportOptions, 'fonts' | 'measurer' | 'producer' | 'reuseAcrossRevisions'> {
export interface PdfExportOptions extends Omit<OpenFontBackedDocumentForExportOptions, 'fonts' | 'measurer' | 'producer' | 'reuseAcrossRevisions' | 'fontShaping'> {
readonly comments?: boolean;
readonly displayMode?: ReviewDisplayMode;
readonly documentLigatures?: boolean;
readonly fallbackFonts?: PdfFontsSource;
readonly fidelityPolicy?: 'strict' | 'best-effort';
readonly fontShaping?: FontShapingOptions;
readonly fonts?: PdfFontsSource;
readonly lastResortFonts?: PdfFontsSource;
readonly maxOutputBytes?: number;
Expand Down
2 changes: 1 addition & 1 deletion docs/site/content/export/pdf/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Choose best-effort output explicitly after reviewing your application's fidelity

## Resource boundaries

The writer limits operations to `2000000`, diagnostics to `10000`, and uncompressed content to `67108864` bytes. Core also bounds DOCX parsing, fonts, images, and layout.
The writer limits diagnostics to `10000`. It limits paint operations to `2000000` or `6000` for each laid-out page, whichever is greater, up to `40000000`. It limits uncompressed content to `67108864` bytes or `196608` bytes for each page, whichever is greater, up to `536870912` bytes. Text shaping accepts font faces up to `41943040` bytes (40 MiB) unless you set `fontShaping`, so large installed CJK font collections are used rather than replaced. Core also bounds DOCX parsing, fonts, images, and layout.

`maxPages` and `maxOutputBytes` do not impose a process memory limit. They check completed layout and completed encoding, respectively.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ describe('footnote hold-out uses the returning paragraph opening', () => {
14
);
// The joined reference ends at 644pt. The separator and minimum body band take 28pt.
// The reference line stays out; the five lines ahead of it return, and widow control
// keeps the last line with it.
for (const height of [648, 672]) {
const bodyPage = { ...input.bodyPage, contentBox: { ...input.bodyPage.contentBox, height } };
const nextPage = {
Expand All @@ -128,7 +130,7 @@ describe('footnote hold-out uses the returning paragraph opening', () => {
};
for (const usedReservePt of [undefined, height - 560 - 0.5]) {
const hold = holdOutReserveNeed({ ...input, bodyPage, nextPage, usedReservePt });
expect(hold).toBeCloseTo(height - 560 - 0.5, 6);
expect(hold).toBeCloseTo(height - 560 - 5 * 14 - 0.5, 6);
}
}
// A positive budget can require splitting a taller note; retain that existing release.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// The footnote hold-out keeps a reference line off the previous page, not the lines ahead
// of it. A paragraph that continues onto the next page and cites a note on a later line
// returns its earlier lines, as far as widow and orphan control allow.

import { describe, expect, test } from 'bun:test';
import { readOoxmlPart } from '../../store/package/ooxml-tree.ts';
import { createFixedMeasurer } from '../fixed-measurer.ts';
import { holdOutReserveNeed, type HoldOutArgs } from '../note-reserve-holdout.ts';
import type { PageRecord, ParagraphFragmentRecord } from '../semantic-records.ts';

const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const LINE = 14;
const CONTENT = 648;

function notes(lines: number) {
const runs = Array.from(
{ length: lines },
(_, line) => `<w:r>${line ? '<w:br/>' : ''}<w:t>N${line + 1}</w:t></w:r>`
).join('');
const result = readOoxmlPart(
`<w:footnotes xmlns:w="${W}"><w:footnote w:id="1"><w:p><w:pPr><w:spacing w:line="280" w:lineRule="exact"/></w:pPr>${runs}</w:p></w:footnote></w:footnotes>`,
{ name: '/word/footnotes.xml', contentType: 'application/xml' }
);
if (!result.ok) throw new Error(result.reason);
return result.part;
}

function paragraph(
id: string,
top: number,
count: number,
options: { start?: number; continuation?: boolean; end?: boolean; props?: string[] } = {}
): ParagraphFragmentRecord {
const start = options.start ?? 0;
return {
kind: 'paragraph',
id: `${id}#${start}`,
paragraphId: id,
fragmentIndex: options.continuation ? 1 : 0,
paragraphEnd: options.end ?? true,
range: { start, end: start + count * 10 },
box: { x: 0, y: top, width: 468, height: count * LINE },
props: (options.props ?? []).map((localName) => ({
kind: 'generic',
localName,
attributes: {},
children: [],
})),
spacing: { before: 0, after: 0, line: null, lineRule: 'auto' },
indent: { start: 0, end: 0, firstLine: 0, hanging: 0 },
lines: Array.from({ length: count }, (_, index) => ({
id: `${id}-${start}-${index}`,
range: { paragraphId: id, start: start + index * 10, end: start + (index + 1) * 10 },
spans: [],
box: { x: 0, y: top + index * LINE, width: 468, height: LINE },
contentX: 0,
baseline: 11,
leading: 0,
})),
} as ParagraphFragmentRecord;
}

function page(index: number, fragments: readonly ParagraphFragmentRecord[]): PageRecord {
return {
id: `page-${index}`,
index,
box: { x: 0, y: 0, width: 612, height: 792 },
contentBox: { x: 72, y: 72, width: 468, height: CONTENT },
fragments,
};
}

/**
* A page of 40 filler lines, then `opening` lines of the cited paragraph; the next page
* holds the paragraph's other `rest` lines, the last of which cites a one-line note.
*/
function input(opening: number, rest: number, props: string[] = [], noteLines = 1): HoldOutArgs {
const filler = paragraph('filler', 0, 40);
const fragments =
opening > 0 ? [filler, paragraph('cited', 560, opening, { end: false, props })] : [filler];
const start = opening * 10;
const next = paragraph('cited', 0, rest, { start, continuation: opening > 0, props });
const bodyBottom = 560 + opening * LINE;
return {
bodyPage: page(0, fragments),
nextPage: page(1, [next]),
existingAreaHeight: 0,
ownReservePt: 0,
usedReservePt: CONTENT - bodyBottom - 0.5,
pageBottomRefsOf: (candidate) =>
candidate.index === 1
? [{ noteId: 1, paragraphId: 'cited', atomOffset: start + (rest - 1) * 10 + 5 }]
: [],
footnotesPart: notes(noteLines),
opts: { measurer: createFixedMeasurer(6, 14), producer: 'holdout-leading-lines' },
plainSeparatorHeight: LINE,
noteLayoutCache: new Map(),
};
}

/** Body lines that may return beside a hold: whole lines of the page's free space. */
function returningLines(args: HoldOutArgs): number {
const bodyBottom = args.bodyPage.fragments.reduce(
(bottom, fragment) => Math.max(bottom, fragment.box.y + fragment.box.height),
0
);
return Math.floor((CONTENT - bodyBottom - holdOutReserveNeed(args)) / LINE);
}

describe('footnote hold-out keeps only the reference line out', () => {
test('lines ahead of the reference return, and widow control keeps two beside it', () => {
// Free space for four lines. The reference line and its note cannot both fit, so the
// two lines ahead of the last pair return without it.
expect(returningLines(input(2, 4))).toBe(2);
});

test('a reference on the second of two remaining lines returns nothing', () => {
// The pair and a one-line note fit back, so nothing is held.
expect(returningLines(input(2, 2))).toBe(4);
// A two-line note does not fit; widow control keeps the first line with the reference.
expect(returningLines(input(2, 2, [], 2))).toBe(0);
});

test('without widow control every line ahead of the reference returns', () => {
const off = input(2, 4);
const next = off.nextPage!.fragments[0] as ParagraphFragmentRecord;
const free = {
...next,
props: [
{
kind: 'generic',
localName: 'widowControl',
attributes: { val: '0' },
children: [],
},
],
} as ParagraphFragmentRecord;
expect(returningLines({ ...off, nextPage: page(1, [free]) })).toBe(3);
});

test('a paragraph kept together returns no line', () => {
expect(returningLines(input(2, 4, ['keepLines']))).toBe(0);
});

test('a paragraph that starts on the next page returns at least two lines', () => {
expect(returningLines(input(0, 4))).toBe(2);
});

test('a paragraph citing a second note on the next page keeps the full hold', () => {
const args = input(2, 4);
const second = { noteId: 1, paragraphId: 'cited', atomOffset: 20 + 2 * 10 + 5 };
const refs = args.pageBottomRefsOf;
expect(
returningLines({
...args,
pageBottomRefsOf: (candidate) => [...refs(candidate), ...(candidate.index ? [second] : [])],
})
).toBe(0);
});
});
137 changes: 137 additions & 0 deletions packages/core/src/layout/__tests__/footnote-space-after-fit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'bun:test';
import { zipSync, strToU8 } from 'fflate';
import type { PageRecord } from '../semantic-records.ts';
import { readOoxmlPackage } from '../../store/package/ooxml-package.ts';
import { resolveNotesPart } from '../../store/package/note-references.ts';
import {
resolveEndnoteProperties,
resolveFootnoteProperties,
} from '../../store/package/note-properties.ts';
import { createFixedMeasurer } from '../fixed-measurer.ts';
import { createLayoutSession } from '../layout-session.ts';
import { layoutSemanticDocument } from '../semantic-layout.ts';
import { applyTreeOp } from '../../store/store/tree-ops.ts';

const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
const CT = 'http://schemas.openxmlformats.org/package/2006/content-types';
const REL = 'http://schemas.openxmlformats.org/package/2006/relationships';
/** Page height; the note area is a 12pt separator plus one 12pt note line. */
const PAGE = 100;
const AREA_TOP = PAGE - 24;
const LINE = 12;

const run = (text: string) => `<w:r><w:rPr><w:sz w:val="22"/></w:rPr>${text}</w:r>`;
const p = (text: string, after = 0, height = LINE) =>
`<w:p><w:pPr><w:spacing w:before="0" w:after="${after * 20}" w:line="${height * 20}" w:lineRule="exact"/></w:pPr>${run(text)}</w:p>`;

function load(body: string) {
const entries = {
'[Content_Types].xml': `<Types xmlns="${CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml"/></Types>`,
'_rels/.rels': `<Relationships xmlns="${REL}"><Relationship Id="rId1" Type="${R}/officeDocument" Target="word/document.xml"/></Relationships>`,
'word/_rels/document.xml.rels': `<Relationships xmlns="${REL}"><Relationship Id="rIdFn" Type="${R}/footnotes" Target="footnotes.xml"/></Relationships>`,
'word/document.xml': `<w:document xmlns:w="${W}"><w:body>${body}<w:sectPr><w:pgSz w:w="4000" w:h="${PAGE * 20}"/><w:pgMar w:top="0" w:right="0" w:bottom="0" w:left="0"/></w:sectPr></w:body></w:document>`,
'word/footnotes.xml': `<w:footnotes xmlns:w="${W}"><w:footnote w:type="separator" w:id="-1">${p('<w:separator/>')}</w:footnote><w:footnote w:type="continuationSeparator" w:id="0">${p('<w:continuationSeparator/>')}</w:footnote><w:footnote w:id="1">${p('<w:t>Note</w:t>')}</w:footnote></w:footnotes>`,
};
const bytes = zipSync(
Object.fromEntries(Object.entries(entries).map(([k, v]) => [k, strToU8(v)]))
);
const loaded = readOoxmlPackage(bytes);
if (!loaded.ok) throw Error(loaded.reason);
const part = loaded.package.parts.get(loaded.package.mainDocumentPart)!;
const fn = resolveFootnoteProperties(undefined, undefined);
const en = resolveEndnoteProperties(undefined, undefined);
const measurer = createFixedMeasurer(5, LINE);
const options = (session = createLayoutSession()) => ({
measurer,
session,
notes: {
footnotesPart: resolveNotesPart(loaded.package, 'footnote'),
endnotesPart: null,
documentFootnoteProps: fn,
footnotePropsBySection: [fn],
documentEndnoteProps: en,
endnotePropsBySection: [en],
measurer,
producer: 'footnote-space-after-fit-test',
},
});
return { part, options };
}

function layout(body: string) {
const { part, options } = load(body);
return layoutSemanticDocument(part, 1, options());
}

const pageOf = (pages: readonly PageRecord[], text: string): number =>
pages.findIndex((page) =>
page.fragments.some(
(fragment) =>
fragment.kind === 'paragraph' &&
fragment.lines.some((line) => line.spans.some((span) => span.text.includes(text)))
)
);

describe('space after above a footnote area', () => {
// A spacer pushes the contested line so `room` points remain between its box and the area.
const spacer = (room: number) => p('<w:t>Spacer</w:t>', 0, AREA_TOP - room - LINE);

test('a citing line stays only when its space after fits too', () => {
const cited = (room: number, after: number) =>
layout(spacer(room) + p('<w:t>Cited</w:t><w:footnoteReference w:id="1"/>', after));
expect(pageOf(cited(5, 10).pages, 'Cited')).toBe(1);
expect(pageOf(cited(10, 10).pages, 'Cited')).toBe(0);
expect(pageOf(cited(1, 0).pages, 'Cited')).toBe(0);
});

test('a plain closing line above the notes needs its space after', () => {
const plain = (room: number, after: number) =>
layout(
p('<w:t>Opening</w:t><w:footnoteReference w:id="1"/>') +
p('<w:t>Spacer</w:t>', 0, AREA_TOP - room - 2 * LINE) +
p('<w:t>Closing</w:t>', after) +
p('<w:t>Next</w:t>')
);
expect(pageOf(plain(5, 10).pages, 'Closing')).toBe(1);
expect(pageOf(plain(12, 10).pages, 'Closing')).toBe(0);
});

test('a page without a citation keeps the space after out of the fit', () => {
const pages = layout(
p('<w:t>Spacer</w:t>', 0, PAGE - 2 * LINE) +
p('<w:t>Closing</w:t>', 10) +
p('<w:t>Next</w:t>')
).pages;
expect(pageOf(pages, 'Closing')).toBe(0);
});
});

test('a retained session matches a cold pass as a citing paragraph grows', () => {
// The citing opening paragraph wraps as it grows and pushes the closing paragraph down
// across its space-after threshold above the note area.
const doc = load(
p('<w:t xml:space="preserve">Opening </w:t><w:footnoteReference w:id="1"/>') +
p('<w:t>Spacer</w:t>', 0, AREA_TOP - 20 - 2 * LINE) +
p('<w:t>Closing</w:t>', 10) +
p('<w:t>Next</w:t>')
);
let part = doc.part;
const session = createLayoutSession();
let result = layoutSemanticDocument(part, 1, doc.options(session));
const opening = result.pages[0]!.fragments[0]!;
if (opening.kind !== 'paragraph') throw new Error('expected a paragraph');
for (let index = 0; index < 50; index++) {
const edit = applyTreeOp(part, {
op: 'insertText',
paragraphId: opening.paragraphId,
offset: index,
text: 'w',
});
if (!edit.ok) throw new Error(edit.reason);
part = edit.part;
result = layoutSemanticDocument(part, 2 + index, doc.options(session));
expect(result.pages).toEqual(layoutSemanticDocument(part, 2 + index, doc.options()).pages);
}
expect(pageOf(result.pages, 'Closing')).toBe(1);
});
14 changes: 11 additions & 3 deletions packages/core/src/layout/__tests__/legacy-cjk-line-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,18 @@ test.each([0, 16, 21, 31])('other code pages do not add CJK leading (bit %s)', (
);
});

test('CJK faces with an existing gap or taller face box keep those metrics', () => {
test('a one-em CJK face with a small gap takes the same 1.3em line', () => {
// SimSun declares a one-em box plus 36/256 em of leading; Word lays its 12pt lines
// 15.6pt apart, the same box as a face with no leading.
const metrics = measurer(18, 288).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 12 });
expect(metrics.height).toBeCloseTo(12 * 1.3, 2);
expect(metrics.baseline).toBeCloseTo(12 * (1760 / 2048 + 0.15), 2);
});

test('CJK faces with a larger gap or taller face box keep those metrics', () => {
expect(
measurer(17, 128).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 11 }).height
).toBeCloseTo((11 * 2176) / 2048, 2);
measurer(17, 820).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 11 }).height
).toBeCloseTo((11 * 2868) / 2048, 2);
expect(
measurer(17, 0, 2304).lineMetrics({ ...DEFAULT_RUN_STYLE, fontSizePt: 11 }).height
).toBeCloseTo((11 * 2592) / 2048, 2);
Expand Down
Loading
Loading