fix: a document keeps its look across the formats, not only its words
A header row is w:tblHeader on the row, the same thing ODF spells with <table:table-header-rows>. An index's leader and tab stop ride its first row, the level styles are a family per kind, and the stop is written even with no page number running to it. A box carries its own ring (w:bodyPr/@lIns) and where it sits across the column when behind the text. A section past the first now names its zones even when blank: a sectPr without a headerReference is Word's Link to Previous, which put a chapter's running head on the pages a blank section was meant for.
This commit is contained in:
1 parent
8b006d22a8
commit
7e037c03a8
6 files changed
+205
-33
No files matched your search
@@ -59,6 +59,14 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
|
||||
- **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex).
|
||||
- **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root. `tests/package-lint.test.ts` guards the invariants the schemas cannot express (unique style ids, no dangling style/num/rel references, balanced ranges, manifest completeness).
|
||||
|
||||
- **A section past the first spells its zones out, blank ones included.** A `w:sectPr`
|
||||
naming no `w:headerReference` is Word's "Link to Previous" and repeats the section above
|
||||
it — measured: a chapter's running head landed on the pages a blank section was meant
|
||||
for. Both zones write an empty part rather than none (`spellOut`, `mkHeaders`).
|
||||
- **A box spanning the text column keeps where it sits across it**, banded or behind the
|
||||
text alike; only a side wrap has its side dictated by the wrap itself.
|
||||
- **An index row's tab stop is written even with no page number running to it** — it is
|
||||
where the row's leader is kept, and a stop no tab reaches draws nothing.
|
||||
- **A heading writes its own `w:outlineLvl`** beside its `w:pStyle`: the style carries the
|
||||
look, the level says it is a heading at all, and a document setting its chapters in a
|
||||
style of its own leaves the importer nothing else to go by.
|
||||
|
||||
+39
-13
@@ -853,10 +853,14 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number |
|
||||
if (wrap === 'through') {
|
||||
// Word's in-front-of / behind-text: no wrap at all, and behindDoc names which side
|
||||
// of the text the frame lands on. It overlaps by definition, so allowOverlap holds.
|
||||
// Where it sits across the text column is its own, as it is for a wrapped frame:
|
||||
// a full-width figure behind the text is centred, not flush left.
|
||||
const through = alignH === 'right' ? HorizontalPositionAlign.RIGHT
|
||||
: alignH === 'center' ? HorizontalPositionAlign.CENTER : HorizontalPositionAlign.LEFT;
|
||||
return {
|
||||
horizontalPosition: offsetCm != null
|
||||
? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) }
|
||||
: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
|
||||
: { relative: HorizontalPositionRelativeFrom.MARGIN, align: through },
|
||||
verticalPosition,
|
||||
wrap: { type: TextWrappingType.NONE },
|
||||
behindDocument: !inFront,
|
||||
@@ -868,9 +872,12 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number |
|
||||
// may overlap vertically — that is what puts them side by side.
|
||||
const end = alignH === 'right' ? HorizontalPositionAlign.RIGHT
|
||||
: alignH === 'left' ? HorizontalPositionAlign.LEFT : null;
|
||||
// Centred is a place in the band, not an end of it: it shares with nobody, so it
|
||||
// keeps the overlap off that a side-by-side pair needs.
|
||||
const mid = alignH === 'center' ? HorizontalPositionAlign.CENTER : null;
|
||||
return {
|
||||
horizontalPosition: end
|
||||
? { relative: HorizontalPositionRelativeFrom.MARGIN, align: end }
|
||||
horizontalPosition: end || mid
|
||||
? { relative: HorizontalPositionRelativeFrom.MARGIN, align: (end ?? mid)! }
|
||||
: offsetCm != null
|
||||
? { relative: HorizontalPositionRelativeFrom.MARGIN, offset: Math.round(offsetCm * 360000) }
|
||||
: { relative: HorizontalPositionRelativeFrom.MARGIN, align: HorizontalPositionAlign.LEFT },
|
||||
@@ -949,6 +956,7 @@ type TextBoxDocx = {
|
||||
fill: string | null;
|
||||
stroke: string | null;
|
||||
strokeWidthPt: number;
|
||||
paddingCm: number;
|
||||
content: TiptapNode[];
|
||||
};
|
||||
|
||||
@@ -971,6 +979,8 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
|
||||
fill: typeof a.fillColor === 'string' && a.fillColor ? a.fillColor : null,
|
||||
stroke: typeof a.strokeColor === 'string' && a.strokeColor ? a.strokeColor : null,
|
||||
strokeWidthPt: typeof a.strokeWidthPt === 'number' && a.strokeWidthPt > 0 ? a.strokeWidthPt : 1,
|
||||
// The attr is only set where the box disagrees with the editor's own ring.
|
||||
paddingCm: typeof a.paddingCm === 'number' && a.paddingCm >= 0 ? a.paddingCm : TEXTBOX_PADDING_CM,
|
||||
content: node.content ?? [],
|
||||
};
|
||||
}
|
||||
@@ -1315,7 +1325,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s
|
||||
const ln = box.stroke
|
||||
? `<a:ln w="${Math.round(box.strokeWidthPt * EMU_PER_PT)}"><a:solidFill><a:srgbClr val="${hexColor(box.stroke) ?? '000000'}"/></a:solidFill>${ends}</a:ln>`
|
||||
: '<a:ln><a:noFill/></a:ln>';
|
||||
const inset = Math.round(TEXTBOX_PADDING_CM * EMU_PER_CM);
|
||||
const inset = Math.round(box.paddingCm * EMU_PER_CM);
|
||||
// The box keeps the height it declares — LibreOffice's own DOCX export writes this for
|
||||
// the same frame, and read as spAutoFit it lays the text out detached from the shape
|
||||
// (probed: the text lands in the body, over whatever follows).
|
||||
@@ -1352,9 +1362,10 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s
|
||||
const wrapEl = box.wrap === 'through' ? '<wp:wrapNone/>'
|
||||
: box.wrap === 'topBottom' ? '<wp:wrapTopAndBottom/>'
|
||||
: `<wp:wrapSquare wrapText="${box.wrap === 'right' ? 'left' : 'right'}"/>`;
|
||||
// A side wrap names the side itself; only a band-wrapped box has a place to choose.
|
||||
// A side wrap names the side itself; a box spanning the column — banded or behind the
|
||||
// text — has a place across it to choose, and a figure usually sits in the middle.
|
||||
const align = box.wrap === 'right' ? 'right'
|
||||
: box.wrap === 'topBottom' ? box.alignH ?? 'left' : 'left';
|
||||
: box.wrap === 'topBottom' || box.wrap === 'through' ? box.alignH ?? 'left' : 'left';
|
||||
const emu = (cm: number) => Math.round(cm * 360000);
|
||||
// The x rides topBottom too: it moves the frame within its full-width band.
|
||||
const posH = box.offsetCm != null
|
||||
@@ -2339,7 +2350,10 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
|
||||
}
|
||||
return new TableRow({
|
||||
height: typeof rh === 'number' && rh > 0 ? { value: pxToTwip(rh), rule: HeightRule.ATLEAST } : undefined,
|
||||
...(repeatHeader && rowIndex === 0 ? { tableHeader: true } : {}),
|
||||
// The flag marks a header row, whether the table asked for the repeat or only the
|
||||
// row's own cells say they head it — ODF spells both with one element.
|
||||
...((repeatHeader && rowIndex === 0) || (row.content ?? []).some((c) => c.type === 'tableHeader')
|
||||
? { tableHeader: true } : {}),
|
||||
children: cells,
|
||||
});
|
||||
});
|
||||
@@ -2376,7 +2390,9 @@ function indexFieldParagraphs(node: TiptapNode, kind: IndexKind, maxLevel: numbe
|
||||
const a = node.attrs ?? {};
|
||||
const noPages = a.pageNumbers === false;
|
||||
const instr =
|
||||
kind === 'alphabetical' ? 'INDEX \\c "1" \\e "\t"'
|
||||
// `\n` is a TOC switch that INDEX has no counterpart for; Word ignores the unknown
|
||||
// one and regenerates its rows, and this side reads it back.
|
||||
kind === 'alphabetical' ? `INDEX \\c "1" \\e "\t"${noPages ? ' \\n' : ''}`
|
||||
: kind === 'bibliography' ? 'BIBLIOGRAPHY'
|
||||
// `\n` over the whole range: Word's switch takes levels, the editor's index is
|
||||
// all-or-nothing.
|
||||
@@ -2409,7 +2425,9 @@ function indexFieldParagraphs(node: TiptapNode, kind: IndexKind, maxLevel: numbe
|
||||
return entries.map((e, i) => new Paragraph({
|
||||
style: own(e.level) ? docxStyleId(own(e.level)!) : undefined,
|
||||
indent: own(e.level) || e.level === 1 ? undefined : { left: cmToTwip(0.5 * (e.level - 1)) },
|
||||
tabStops: noPage ? undefined : [{ type: TabStopType.RIGHT, position: cmToTwip(tabCm), ...(leader ? { leader } : {}) }],
|
||||
// Written even with no page number running to it: it is where the row's leader is
|
||||
// kept, and a stop no tab reaches changes nothing on the page.
|
||||
tabStops: [{ type: TabStopType.RIGHT, position: cmToTwip(tabCm), ...(leader ? { leader } : {}) }],
|
||||
children: [
|
||||
...(i === 0 ? open : []),
|
||||
...e.text.split('\n').map((part, li) => new TextRun(li ? { text: part, break: 1 } : { text: part })),
|
||||
@@ -2826,23 +2844,31 @@ export async function buildDocx(
|
||||
// needs one — empty where the zone has no text — for the post-passes to inject
|
||||
// into; a variant without its own part would blank the decor on those pages.
|
||||
const decorated = Boolean(decor.watermark?.text) || foldMarks;
|
||||
// A section past the first says what it has, blank included: a zone naming no part
|
||||
// of its own is Word's "Link to Previous" and repeats the section above it — which
|
||||
// put a chapter's running head on the pages a blank one was meant for.
|
||||
const spellOut = decorated || i > 0;
|
||||
const h: { default?: Header; first?: Header; even?: Header } = {};
|
||||
if (d) h.default = new Header({ children: [paragraphToDocx(d)] });
|
||||
else if (decorated) h.default = new Header({ children: [new Paragraph({})] });
|
||||
else if (spellOut) h.default = new Header({ children: [new Paragraph({})] });
|
||||
if (f) h.first = new Header({ children: [paragraphToDocx(f)] });
|
||||
else if (s.differentFirstPage && decorated) h.first = new Header({ children: [new Paragraph({})] });
|
||||
else if (s.differentFirstPage && spellOut) h.first = new Header({ children: [new Paragraph({})] });
|
||||
if (e) h.even = new Header({ children: [paragraphToDocx(e)] });
|
||||
else if (s.differentOddEven && decorated) h.even = new Header({ children: [new Paragraph({})] });
|
||||
else if (s.differentOddEven && spellOut) h.even = new Header({ children: [new Paragraph({})] });
|
||||
return Object.keys(h).length ? h : undefined;
|
||||
};
|
||||
const mkFooters = (i: number) => {
|
||||
const s = setAt(i);
|
||||
const d = para(s.footer), f = s.differentFirstPage ? para(s.footerFirst) : null, e = s.differentOddEven ? para(s.footerEven) : null;
|
||||
if (!d && !f && !e) return undefined;
|
||||
if (!d && !f && !e && i === 0) return undefined;
|
||||
const fo: { default?: Footer; first?: Footer; even?: Footer } = {};
|
||||
// Spelled out past the first section, for the reason the headers are.
|
||||
if (d) fo.default = new Footer({ children: [paragraphToDocx(d)] });
|
||||
else if (i > 0) fo.default = new Footer({ children: [new Paragraph({})] });
|
||||
if (f) fo.first = new Footer({ children: [paragraphToDocx(f)] });
|
||||
else if (s.differentFirstPage && i > 0) fo.first = new Footer({ children: [new Paragraph({})] });
|
||||
if (e) fo.even = new Footer({ children: [paragraphToDocx(e)] });
|
||||
else if (s.differentOddEven && i > 0) fo.even = new Footer({ children: [new Paragraph({})] });
|
||||
return fo;
|
||||
};
|
||||
|
||||
|
||||
@@ -108,6 +108,17 @@ Body text with no resolved font falls back to the *document's own theme minor fo
|
||||
on the page layout governing the body (`HfSet.pageNumberFormat`), which is how a roman
|
||||
front matter precedes a decimal body; Word's is `w:pgNumType w:fmt` per section, and the
|
||||
document's own comes from the **first** section, as the paper and the margins already do.
|
||||
- **A header row is `w:tblHeader` on the row**, which says what ODF's
|
||||
`<table:table-header-rows>` says: its cells become `tableHeader`, and the export writes
|
||||
the flag for a row whose cells are header cells even where the table asks for no repeat.
|
||||
- **An index's look rides its first row.** The field opens on that row, so the leader and
|
||||
the right tab stop the page number runs to are read off the very paragraph the node is
|
||||
built from; a stop at the text width names no position of its own and is dropped, as on
|
||||
the ODF side. The level styles are a family per index kind (`INDEX_LEVEL_STYLES`) —
|
||||
`Contents n` for a contents, `Illustration Index n`, `Table index n`, `Index n` — since
|
||||
Word regenerates each kind's rows from its own.
|
||||
- **A text box's ring is `w:bodyPr/@lIns`** (EMU), suppressed against the editor's own
|
||||
0.15cm as the ODF side suppresses `fo:padding`.
|
||||
- **Chapter numbering** — `text:outline-style` on the ODF side, the heading styles'
|
||||
`w:numPr` on Word's — becomes `StyleSheet.outline`; see `src/lib/styles/CLAUDE.md`.
|
||||
A numbered heading is therefore **never a list item** (`paragraphNum` returns null for
|
||||
|
||||
+55
-12
@@ -9,6 +9,7 @@ import { DEFAULT_OUTLINE_LEVEL, MAX_OUTLINE_LEVELS, type OutlineNumbering } from
|
||||
import { MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { fitInlineImage, framePx } from '../editor/extensions/image';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { formatTabStops } from '../editor/extensions/tabStops';
|
||||
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
|
||||
import { builtinTableStyles, parseTableLook, resolveTableCell, tableLookAttr } from '../styles/tableStyles';
|
||||
@@ -352,6 +353,19 @@ function documentLanguage(stylesDoc: Document | null, warnings: Set<string>): Do
|
||||
}
|
||||
|
||||
// ---- block conversion (paragraphs, lists, tables) --------------------------
|
||||
// An index row runs its page number out to a right tab stop, and the dots in between are
|
||||
// that stop's leader. The field opens on its own first row, so the look sits on the very
|
||||
// paragraph the node is built from.
|
||||
function tocRowTab(el: Element, ctx: Ctx): { leader?: string; tabPosCm?: number } {
|
||||
const tabs = fc(fc(el, 'pPr'), 'tabs');
|
||||
const stop = tabs ? readTabStops(tabs).find((t) => t.align === 'right') : undefined;
|
||||
if (!stop) return {};
|
||||
// A stop at the text width is where a row's number lands anyway, so it names no
|
||||
// position of its own — the ODF side suppresses it the same way.
|
||||
const own = stop.pos > 0 && Math.abs(stop.pos - ctx.contentWidthCm) > 0.05;
|
||||
return { ...(stop.leader ? { leader: stop.leader } : {}), ...(own ? { tabPosCm: stop.pos } : {}) };
|
||||
}
|
||||
|
||||
// A TOC is a `TOC` field spanning several paragraphs, each entry a nested PAGEREF field,
|
||||
// so field depth is tracked (across one convertBlocks call) to match the TOC's own end.
|
||||
type TocFieldState = { fieldDepth: number; tocDepth: number; instr: string[] };
|
||||
@@ -546,11 +560,12 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
// The field carries no heading of its own — Word's sits in a separate paragraph.
|
||||
const instr = instrTextOf(el);
|
||||
const index = tocIndexKind(instr);
|
||||
// maxLevel only where the field has levels — the ODF side sets none for the
|
||||
// alphabetical index or the bibliography either.
|
||||
const levels = index === 'alphabetical' || index === 'bibliography' ? null : tocMaxLevel(instr);
|
||||
const levelStyles = levels == null ? null : tocLevelStyles(ctx, levels);
|
||||
// A bibliography has no levels; every other index does, the alphabetical one's
|
||||
// being its own three, as the ODF side reads them off its entry templates.
|
||||
const levels = index === 'bibliography' ? null : tocMaxLevel(instr);
|
||||
const levelStyles = levels == null ? null : tocLevelStyles(ctx, levels, index);
|
||||
out.push({ type: 'tableOfContents', attrs: { entries: [], title: '', index, ...tocPageNumbers(instr),
|
||||
...tocRowTab(el, ctx),
|
||||
...(levels == null ? {} : { maxLevel: levels }),
|
||||
...(levelStyles ? { levelStyles } : {}),
|
||||
...(index === 'bibliography' ? { citationStyle: ctx.citationStyle } : {}) } });
|
||||
@@ -606,12 +621,13 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
const content = fc(el, 'sdtContent');
|
||||
const instr = content ? instrTextOf(content) : '';
|
||||
const maxLevel = tocMaxLevel(instr);
|
||||
const levelStyles = tocLevelStyles(ctx, maxLevel);
|
||||
const sdtKind = tocIndexKind(instr);
|
||||
const levelStyles = tocLevelStyles(ctx, maxLevel, sdtKind);
|
||||
out.push({ type: 'tableOfContents', attrs: {
|
||||
entries: [],
|
||||
title: tocHeading(content) ?? '',
|
||||
maxLevel,
|
||||
index: tocIndexKind(instr),
|
||||
index: sdtKind,
|
||||
...tocPageNumbers(instr),
|
||||
...(levelStyles ? { levelStyles } : {}),
|
||||
} });
|
||||
@@ -828,11 +844,24 @@ function registryName(id: string, wordName: string, isDefault: boolean): string
|
||||
|
||||
// The paragraph styles a TOC field regenerates its rows from, by level. Naming them
|
||||
// gives the index the file's own indent and spacing instead of the editor's fallback.
|
||||
function tocLevelStyles(ctx: Ctx, maxLevel: number): (string | null)[] | null {
|
||||
// Each kind of index has its own family of level styles, LibreOffice's names throughout
|
||||
// (registryName maps Word's localized `toc n` onto them). Word regenerates its rows from
|
||||
// the styles, so the family the index belongs to is what says which ones are its own.
|
||||
const INDEX_LEVEL_STYLES: Record<IndexKind, RegExp | null> = {
|
||||
toc: /^Contents (10|[1-9])$/,
|
||||
figures: /^Illustration Index (10|[1-9])$/,
|
||||
tables: /^Table index (10|[1-9])$/i,
|
||||
alphabetical: /^Index (10|[1-9])$/,
|
||||
bibliography: null,
|
||||
};
|
||||
|
||||
function tocLevelStyles(ctx: Ctx, maxLevel: number, kind: IndexKind = 'toc'): (string | null)[] | null {
|
||||
const pattern = INDEX_LEVEL_STYLES[kind];
|
||||
if (!pattern) return null;
|
||||
const out: (string | null)[] = [];
|
||||
for (const id of ctx.styles.namedParagraphStyles().keys()) {
|
||||
const name = ctx.styleNames.get(id) ?? '';
|
||||
const level = Number(/^Contents (10|[1-9])$/.exec(name)?.[1]);
|
||||
const level = Number(pattern.exec(name)?.[1]);
|
||||
if (!level || level > maxLevel) continue;
|
||||
ctx.usedStyles.add(id);
|
||||
out[level - 1] = name;
|
||||
@@ -2180,8 +2209,8 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
|
||||
// the far end that an image reads as one half of a side-by-side pair.
|
||||
const align = root.getElementsByTagNameNS(WP, 'positionH')[0]
|
||||
?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
|
||||
if (wrap === 'topBottom' && offsetCm == null && (align === 'center' || align === 'right'))
|
||||
attrs.wrapAlign = align;
|
||||
if ((wrap === 'topBottom' || wrap === 'through') && offsetCm == null
|
||||
&& (align === 'center' || align === 'right')) attrs.wrapAlign = align;
|
||||
}
|
||||
|
||||
const fillClr = nsChild(nsChild(spPr, A, 'solidFill'), A, 'srgbClr')?.getAttribute('val');
|
||||
@@ -2201,8 +2230,18 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
|
||||
|
||||
// Vertical text: every one of Word's top-to-bottom flows, the editor having the one
|
||||
// direction the browser lays out (`vert270` reads bottom-to-top and is not one).
|
||||
const vert = nsChild(wsp, WPS, 'bodyPr')?.getAttribute('vert') ?? 'horz';
|
||||
const bodyPr = nsChild(wsp, WPS, 'bodyPr');
|
||||
const vert = bodyPr?.getAttribute('vert') ?? 'horz';
|
||||
if (['vert', 'eaVert', 'mongolianVert', 'wordArtVert'].includes(vert)) attrs.textVertical = true;
|
||||
// The ring inside the box (w:lIns, EMU). Word's own default is 0.25cm on the sides;
|
||||
// only a value the editor's own ring does not already draw is worth the attr.
|
||||
const lIns = bodyPr?.getAttribute('lIns');
|
||||
if (lIns != null) {
|
||||
const padCm = Number(lIns) / 360000;
|
||||
if (Number.isFinite(padCm) && padCm >= 0 && Math.abs(padCm - TEXTBOX_PADDING_CM) > 0.01) {
|
||||
attrs.paddingCm = Math.round(padCm * 1000) / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
const txbxContent = nsChild(nsChild(wsp, WPS, 'txbx'), W, 'txbxContent');
|
||||
const blocks = txbxContent ? unnestBoxes(convertBlocks(Array.from(txbxContent.children), ctx, 'cell'), ctx) : [];
|
||||
@@ -2508,6 +2547,10 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
?? Math.max(1, ...trs.map((tr) => fcAll(tr, 'tc').reduce((n, tc) => n + (intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1), 0)));
|
||||
for (let ri = 0; ri < trs.length; ri++) {
|
||||
const tr = trs[ri];
|
||||
// w:tblHeader says the same as ODF's <table:table-header-rows>: this row heads the
|
||||
// table. Its cells are header cells, which is what shades and bolds them.
|
||||
const trHdr = fc(fc(tr, 'trPr'), 'tblHeader');
|
||||
const isHeaderRow = !!trHdr && onOff(trHdr);
|
||||
const cells: Node[] = [];
|
||||
let col = 0;
|
||||
for (const tc of fcAll(tr, 'tc')) {
|
||||
@@ -2580,7 +2623,7 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
const { formula, format } = cellFormulaOf(tc);
|
||||
if (formula) attrs.formula = formula;
|
||||
if (formula && format) attrs.cellFormat = format;
|
||||
const cell: Node = { type: 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
|
||||
const cell: Node = { type: isHeaderRow ? 'tableHeader' : 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] };
|
||||
cells.push(cell);
|
||||
for (let c = col; c < col + colspan; c++) pending[c] = vMerge === 'restart' ? cell : null;
|
||||
col += colspan;
|
||||
|
||||
@@ -215,21 +215,21 @@ export function isSvgDataUrl(src: unknown): src is string {
|
||||
|
||||
export async function svgToPngDataUrl(src: string, widthPx: number, heightPx: number): Promise<string | null> {
|
||||
if (typeof document === 'undefined' || typeof Image === 'undefined') return null;
|
||||
const w = Math.max(1, Math.round(widthPx * SVG_RASTER_SCALE));
|
||||
const h = Math.max(1, Math.round(heightPx * SVG_RASTER_SCALE));
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(widthPx * SVG_RASTER_SCALE));
|
||||
canvas.height = Math.max(1, Math.round(heightPx * SVG_RASTER_SCALE));
|
||||
// Probed before the image is loaded, not after: jsdom has a document and an Image
|
||||
// whose onload never fires for a data URI, and awaiting that one hangs the caller.
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const img = new Image();
|
||||
await new Promise<void>((ok, fail) => {
|
||||
img.onload = () => ok();
|
||||
img.onerror = () => fail(new Error('svg'));
|
||||
img.src = src;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1417,3 +1417,87 @@ describe('numbered headings under a document stylesheet', () => {
|
||||
expect(named!.attrs.level).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// What a document keeps across the formats besides its words: the look of an index, the
|
||||
// header a section blanks, a box's own ring, a header row.
|
||||
describe('the look a document carries', () => {
|
||||
const SVG = 'data:image/svg+xml;base64,' + btoa('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>');
|
||||
|
||||
it('keeps a header row a header row', async () => {
|
||||
const doc = { type: 'doc', content: [{ type: 'table', attrs: { repeatHeader: true }, content: [
|
||||
{ type: 'tableRow', content: [headerCell('Name'), headerCell('Qty')] },
|
||||
{ type: 'tableRow', content: [cell('Widget'), cell('1')] },
|
||||
] }] } as any;
|
||||
const back = importDocx(await buildDocx(doc)).content as N;
|
||||
expect(walk(back, 'tableHeader')).toHaveLength(2);
|
||||
expect(back.content![0].attrs.repeatHeader).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a header row even where the table asks for no repeat', async () => {
|
||||
const doc = { type: 'doc', content: [{ type: 'table', content: [
|
||||
{ type: 'tableRow', content: [headerCell('H')] },
|
||||
{ type: 'tableRow', content: [cell('b')] },
|
||||
] }] } as any;
|
||||
expect(walk(importDocx(await buildDocx(doc)).content as N, 'tableHeader')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a text box's own padding and where it sits behind the text", async () => {
|
||||
const doc = { type: 'doc', content: [para([
|
||||
{ type: 'textBox', attrs: { width: 300, height: 100, wrap: 'through', wrapAlign: 'center', paddingCm: 0 }, content: [para('in the box')] },
|
||||
])] } as any;
|
||||
const box = walk(importDocx(await buildDocx(doc)).content as N, 'textBox')[0];
|
||||
expect(box.attrs.paddingCm).toBe(0);
|
||||
expect(box.attrs.wrapAlign).toBe('center');
|
||||
});
|
||||
|
||||
it("keeps an index's leader, tab position and per-level styles", async () => {
|
||||
const sheet = builtinStyleSheet();
|
||||
for (const n of [1, 2]) sheet.paragraph[`Illustration Index ${n}`] = { name: `Illustration Index ${n}`, parent: 'Standard', next: 'Standard', para: {}, text: {} };
|
||||
const doc = { type: 'doc', content: [{ type: 'tableOfContents', attrs: {
|
||||
index: 'figures', title: '', leader: '.', tabPosCm: 12, maxLevel: 10,
|
||||
levelStyles: ['Illustration Index 1', 'Illustration Index 2'],
|
||||
entries: [{ text: 'Illustration 1: One', level: 1, page: 2 }],
|
||||
} }] } as any;
|
||||
const toc = walk(importDocx(await buildDocx(doc, undefined, undefined, undefined, undefined, undefined, sheet)).content as N, 'tableOfContents')[0];
|
||||
expect(toc.attrs.leader).toBe('.');
|
||||
expect(toc.attrs.tabPosCm).toBe(12);
|
||||
expect(toc.attrs.levelStyles?.[0]).toBe('Illustration Index 1');
|
||||
});
|
||||
|
||||
it('keeps an index of text alone free of page numbers', async () => {
|
||||
const doc = { type: 'doc', content: [{ type: 'tableOfContents', attrs: {
|
||||
index: 'alphabetical', title: '', pageNumbers: false, leader: '.',
|
||||
entries: [{ text: 'Car to X', level: 1, page: 1 }],
|
||||
} }] } as any;
|
||||
const toc = walk(importDocx(await buildDocx(doc)).content as N, 'tableOfContents')[0];
|
||||
expect(toc.attrs.pageNumbers).toBe(false);
|
||||
expect(toc.attrs.leader).toBe('.');
|
||||
});
|
||||
|
||||
it('blanks a later section instead of repeating the header above it', async () => {
|
||||
const doc = { type: 'doc', content: [
|
||||
para('first section'),
|
||||
para('second section', { sectionBreak: true }),
|
||||
] } as any;
|
||||
const hfTwo = {
|
||||
header: { type: 'doc', content: [{ type: 'paragraph', content: [text('Running head')] }] },
|
||||
footer: null, pageCount: 1,
|
||||
sections: [
|
||||
{ header: { type: 'doc', content: [{ type: 'paragraph', content: [text('Running head')] }] }, footer: null },
|
||||
{ header: null, footer: null }, // the blank one
|
||||
],
|
||||
} as any;
|
||||
const xml = strFromU8(unzipSync(await buildDocx(doc, undefined, undefined, hfTwo))['word/document.xml']);
|
||||
// Every section names its own header part; one that named none would inherit.
|
||||
const sects = xml.match(/<w:sectPr[\s\S]*?<\/w:sectPr>/g) ?? [];
|
||||
expect(sects).toHaveLength(2);
|
||||
for (const s of sects) expect(s).toMatch(/<w:headerReference/);
|
||||
});
|
||||
|
||||
it('exports a document holding an SVG without hanging where no canvas can draw', async () => {
|
||||
const doc = { type: 'doc', content: [para([{ type: 'image', attrs: { src: SVG, width: 8, height: 8 } }])] } as any;
|
||||
// jsdom has an Image whose onload never fires; the rasterizer must not await it.
|
||||
const bytes = await buildDocx(doc);
|
||||
expect(bytes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user