feat: notes round-trip through DOCX
Each class gets its own part via the docx package's footnotes/endnotes maps, counted from 1, and the anchor becomes a reference run. Import reads both parts, skipping the separator entries Word references from nowhere, and flattens a multi-paragraph note to hard breaks. The numbering settings ride w:footnotePr/w:endnotePr in settings.xml — a post-pack pass, as the package exposes neither.
This commit is contained in:
1 parent
5f0f5e7457
commit
3703cea1f2
4 files changed
+220
-7
No files matched your search
+1
-1
@@ -650,7 +650,7 @@
|
||||
try {
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, pageRtl);
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, pageRtl, noteSettings());
|
||||
await saveAsDocx(bytes, suggestedFilenameDocx(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
|
||||
+67
-2
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, InternalHyperlink, Bookmark, Tab,
|
||||
FootnoteReferenceRun, EndnoteReferenceRun,
|
||||
TableOfContents,
|
||||
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
|
||||
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
|
||||
@@ -19,6 +20,7 @@ import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../storage/noteSettings';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
|
||||
@@ -98,6 +100,11 @@ const MTH = '';
|
||||
type FormulaDocx = { latex: string; display: boolean };
|
||||
let docFormulas: FormulaDocx[] = [];
|
||||
|
||||
// Word numbers footnotes and endnotes in separate files, so each class counts from 1.
|
||||
// Filled from the note section before the body is walked, then read by the anchor —
|
||||
// module-level for the same reason as docFormulas.
|
||||
let docNoteIds = new Map<string, { id: number; kind: NoteKind }>();
|
||||
|
||||
// Sentinel wrapping a text box's index in a marker paragraph (same trick as
|
||||
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
|
||||
// the marker for a hand-built <w:drawing><wps:wsp> in a zip post-processing pass.
|
||||
@@ -347,6 +354,13 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
|
||||
continue;
|
||||
}
|
||||
if (node.type === 'crossRef') { emit([crossRefField(node)], bookmark); continue; }
|
||||
if (node.type === 'noteRef') {
|
||||
const note = docNoteIds.get(String(node.attrs?.id ?? ''));
|
||||
if (note) {
|
||||
emit([note.kind === 'endnote' ? new EndnoteReferenceRun(note.id) : new FootnoteReferenceRun(note.id)], bookmark);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
if (node.type === 'hardBreak') {
|
||||
// Carry the run's props so an empty line between two breaks keeps its font size.
|
||||
@@ -745,6 +759,37 @@ function applyMirrorMarginsDocx(bytes: Uint8Array): Uint8Array {
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// Word's num-format ids for the five ODF ones.
|
||||
const DOCX_NUM_FMT: Record<string, string> = {
|
||||
'1': 'decimal', a: 'lowerLetter', A: 'upperLetter', i: 'lowerRoman', I: 'upperRoman',
|
||||
};
|
||||
|
||||
// Post-pack pass: the document-wide <w:footnotePr>/<w:endnotePr>, which the docx
|
||||
// package does not expose. Word keeps its defaults in settings.xml, beside
|
||||
// w:mirrorMargins.
|
||||
function applyNotePrDocx(bytes: Uint8Array, notes: NoteSettings): Uint8Array {
|
||||
const files = unzipSync(bytes);
|
||||
const setBytes = files['word/settings.xml'];
|
||||
if (!setBytes) return bytes;
|
||||
const block = (kind: NoteKind) => {
|
||||
const s = notes[kind];
|
||||
const tag = kind === 'footnote' ? 'footnotePr' : 'endnotePr';
|
||||
// Word's restart values; ODF's 'chapter' is its 'eachSect'.
|
||||
const restart = s.restart === 'page' ? 'eachPage' : s.restart === 'chapter' ? 'eachSect' : 'continuous';
|
||||
const pos = kind === 'footnote'
|
||||
? `<w:pos w:val="${s.position === 'document' ? 'docEnd' : 'pageBottom'}"/>`
|
||||
: '<w:pos w:val="docEnd"/>';
|
||||
return `<w:${tag}>${pos}<w:numFmt w:val="${DOCX_NUM_FMT[s.numFormat] ?? 'decimal'}"/>`
|
||||
+ `<w:numStart w:val="${Math.max(1, s.startAt)}"/><w:numRestart w:val="${restart}"/></w:${tag}>`;
|
||||
};
|
||||
files['word/settings.xml'] = strToU8(
|
||||
strFromU8(setBytes).replace(/(<w:settings\b[^>]*>)/, `$1${block('footnote')}${block('endnote')}`),
|
||||
);
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// Post-pack pass: a right-to-left section is <w:bidi/> in every w:sectPr, which the
|
||||
// docx package does not expose (it has the paragraph-level flag only).
|
||||
function applyBidiDocx(bytes: Uint8Array): Uint8Array {
|
||||
@@ -1246,6 +1291,7 @@ export async function buildDocx(
|
||||
styles: StyleSheet = builtinStyleSheet(),
|
||||
tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM,
|
||||
rtl = false,
|
||||
notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS,
|
||||
): Promise<Uint8Array> {
|
||||
docLangTag = localeTag(language ? language.language : 'en');
|
||||
exportSheet = styles;
|
||||
@@ -1256,7 +1302,23 @@ export async function buildDocx(
|
||||
const contentWidthCm = pageWidthCm - margins.left - margins.right;
|
||||
|
||||
const textBoxes: TextBoxDocx[] = [];
|
||||
const groups = bodyGroups(docJson.content ?? [], num, contentWidthCm, textBoxes);
|
||||
// The note section holds the notes in anchor order (notes.ts), which is the order
|
||||
// Word numbers them in; each class counts from 1 because each has its own part.
|
||||
// Numbered before the body is walked, so an anchor already knows its id.
|
||||
const noteBlocks = (docJson.content ?? []).filter((n) => n.type === 'noteSection')
|
||||
.flatMap((n) => n.content ?? []);
|
||||
docNoteIds = new Map();
|
||||
const notesByClass: Record<NoteKind, Record<string, { children: Paragraph[] }>> = { footnote: {}, endnote: {} };
|
||||
for (const note of noteBlocks) {
|
||||
const kind: NoteKind = note.attrs?.kind === 'endnote' ? 'endnote' : 'footnote';
|
||||
const id = Object.keys(notesByClass[kind]).length + 1;
|
||||
docNoteIds.set(String(note.attrs?.id ?? ''), { id, kind });
|
||||
notesByClass[kind][String(id)] = {
|
||||
children: [new Paragraph({ style: kind === 'endnote' ? 'EndnoteText' : 'FootnoteText', children: inlineToRuns(note.content ?? []) })],
|
||||
};
|
||||
}
|
||||
const body = (docJson.content ?? []).filter((n) => n.type !== 'noteSection');
|
||||
const groups = bodyGroups(body, num, contentWidthCm, textBoxes);
|
||||
|
||||
// A TOC field is empty until its field is calculated; ask the reader to update fields
|
||||
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
|
||||
@@ -1325,6 +1387,8 @@ export async function buildDocx(
|
||||
...(hasToc ? { features: { updateFields: true } } : {}),
|
||||
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles)),
|
||||
numbering: { config: num.config },
|
||||
...(Object.keys(notesByClass.footnote).length ? { footnotes: notesByClass.footnote } : {}),
|
||||
...(Object.keys(notesByClass.endnote).length ? { endnotes: notesByClass.endnote } : {}),
|
||||
// A columns group is its own section; w:type=continuous describes the break
|
||||
// BEFORE a section, so it goes on every section but the first.
|
||||
sections: groups.map((g, i) => ({
|
||||
@@ -1344,6 +1408,7 @@ export async function buildDocx(
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
|
||||
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(packed) : packed;
|
||||
const withNotes = docNoteIds.size ? applyNotePrDocx(packed, notesSettings) : packed;
|
||||
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
|
||||
return rtl ? applyBidiDocx(mirrored) : mirrored;
|
||||
}
|
||||
+108
-4
@@ -8,7 +8,7 @@ import { fitInlineImage, framePx } from '../editor/extensions/image';
|
||||
import { formatTabStops } from '../editor/extensions/tabStops';
|
||||
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
|
||||
import { tableLookAttr } from '../styles/tableStyles';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
@@ -18,7 +18,7 @@ import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval';
|
||||
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
|
||||
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { DEFAULT_NOTE_SETTINGS } from '../storage/noteSettings';
|
||||
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
|
||||
import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
|
||||
import { chartDataUrl } from './chart';
|
||||
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
|
||||
@@ -64,8 +64,27 @@ type Ctx = {
|
||||
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
|
||||
// paragraph and end inside a later one, so the state outlives both walks.
|
||||
openBookmarks: Map<string, string>;
|
||||
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
|
||||
// anchor order — the editor keeps them in one section at the document end (notes.ts).
|
||||
noteParts: Record<NoteKind, Map<string, Element>>;
|
||||
notes: { id: string; kind: NoteKind; text: string; content: Node[] }[];
|
||||
};
|
||||
|
||||
// The real notes of a part, by w:id: Word's own separator entries carry a w:type and
|
||||
// are referenced by nothing.
|
||||
function noteParts(files: Record<string, Uint8Array>, part: string, tag: string): Map<string, Element> {
|
||||
const out = new Map<string, Element>();
|
||||
const bytes = files[`word/${part}.xml`];
|
||||
if (!bytes) return out;
|
||||
const root = parseXml(strFromU8(bytes)).documentElement;
|
||||
for (const el of Array.from(root.getElementsByTagNameNS(W, tag))) {
|
||||
if (el.getAttributeNS(W, 'type')) continue;
|
||||
const id = el.getAttributeNS(W, 'id');
|
||||
if (id != null) out.set(id, el);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- units & editor defaults to suppress -----------------------------------
|
||||
const twipToCm = (tw: number) => (tw / 1440) * 2.54;
|
||||
const twipToPt = (tw: number) => tw / 20;
|
||||
@@ -145,7 +164,10 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
const sectPr = fc(body, 'sectPr');
|
||||
const contentWidthCm = sectionContentWidthCm(sectPr);
|
||||
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
|
||||
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map() };
|
||||
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), notes: [], noteParts: {
|
||||
footnote: noteParts(files, 'footnotes', 'footnote'),
|
||||
endnote: noteParts(files, 'endnotes', 'endnote'),
|
||||
} };
|
||||
|
||||
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
|
||||
// more than one column becomes a columns node (the trailing group is described
|
||||
@@ -171,6 +193,16 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
|
||||
pairAlignedFrames(blocks, Math.floor(cmToPx(ctx.contentWidthCm)));
|
||||
|
||||
// The notes the walk collected, in anchor order, as the one section the editor keeps
|
||||
// at the document end.
|
||||
if (ctx.notes.length) {
|
||||
blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({
|
||||
type: 'note',
|
||||
attrs: { id: n.id, kind: n.kind, label: null, text: n.text },
|
||||
...(n.content.length ? { content: n.content } : {}),
|
||||
})) });
|
||||
}
|
||||
|
||||
// Odd/even pages: a document-level setting (settings.xml), not a section property.
|
||||
const oddEven = docHasEvenOddHeaders(files);
|
||||
const mirrored = docHasMirrorMargins(files);
|
||||
@@ -188,7 +220,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
return {
|
||||
content: { type: 'doc', content: blocks },
|
||||
styles: collectStyleSheet(ctx),
|
||||
notes: DEFAULT_NOTE_SETTINGS,
|
||||
notes: docNoteSettings(files),
|
||||
margins: withMirror(first.margins ?? sect.margins, mirrored),
|
||||
// A right-to-left section (w:bidi): the columns fill from the right.
|
||||
rtl: !!finalSectPr && (() => { const b = fc(finalSectPr, 'bidi'); return !!b && onOff(b); })(),
|
||||
@@ -1012,6 +1044,33 @@ function inlineChildren(el: Element): Element[] {
|
||||
});
|
||||
}
|
||||
|
||||
// A w:footnoteReference → the anchor node, with the note's own paragraphs collected
|
||||
// into ctx for the section the document end gets. Word's numbering is implicit, so the
|
||||
// citation is counted here; several paragraphs flatten to hard breaks.
|
||||
function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults): Node | null {
|
||||
const note = wid == null ? null : ctx.noteParts[kind].get(wid);
|
||||
if (!note) return null;
|
||||
const content: Node[] = [];
|
||||
for (const para of Array.from(note.children)) {
|
||||
if (para.namespaceURI !== W || para.localName !== 'p') continue;
|
||||
const runs = convertInline(para, ctx, baseRun, defaults, false);
|
||||
if (content.length && runs.length) content.push({ type: 'hardBreak' });
|
||||
content.push(...runs);
|
||||
}
|
||||
// Word opens the note with its own marker run and a tab; the editor draws both from
|
||||
// the note's own indent, so the leading tab would be a second one.
|
||||
const first = content[0];
|
||||
if (first?.type === 'text' && typeof first.text === 'string') {
|
||||
first.text = first.text.replace(/^\t+/, '');
|
||||
if (!first.text) content.shift();
|
||||
}
|
||||
const seen = ctx.notes.filter((n) => n.kind === kind).length;
|
||||
const text = formatOrdinal(seen + 1, kind === 'endnote' ? 'i' : '1');
|
||||
const id = `${kind}${wid}`;
|
||||
ctx.notes.push({ id, kind, text, content });
|
||||
return { type: 'noteRef', attrs: { id, kind, text } };
|
||||
}
|
||||
|
||||
function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] {
|
||||
const out: Node[] = [];
|
||||
let fieldMode: 'none' | 'instr' | 'result' = 'none';
|
||||
@@ -1146,6 +1205,15 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
case 'cr': out.push(hardBreakNode(marks)); break;
|
||||
case 'drawing': pushDrawn(convertDrawing(child, ctx), drawingIsFloating(child)); break;
|
||||
case 'pict': pushDrawn(convertPict(child, ctx), drawingIsFloating(child)); break;
|
||||
case 'footnoteReference':
|
||||
case 'endnoteReference': {
|
||||
// The zone schema has no notes, and Word's own separator notes are referenced
|
||||
// by nothing — only a real anchor reaches here.
|
||||
const kind: NoteKind = child.localName === 'endnoteReference' ? 'endnote' : 'footnote';
|
||||
const ref = hfFields ? null : noteRefNode(child.getAttributeNS(W, 'id'), kind, ctx, baseRun, defaults);
|
||||
if (ref) out.push(ref);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2070,6 +2138,42 @@ function docHasMirrorMargins(files: Record<string, Uint8Array>): boolean {
|
||||
}
|
||||
|
||||
// Word's "Different Odd & Even Pages" toggle lives in settings.xml, not the sectPr.
|
||||
// Word's num-format ids, reversed onto the five ODF ones.
|
||||
const NUM_FMT_FROM_DOCX: Record<string, NoteNumFormat> = {
|
||||
decimal: '1', lowerLetter: 'a', upperLetter: 'A', lowerRoman: 'i', upperRoman: 'I',
|
||||
};
|
||||
|
||||
// The document-wide <w:footnotePr>/<w:endnotePr> in settings.xml. The separator is not
|
||||
// Word's to describe — it draws a fixed one — so it keeps the editor's.
|
||||
function docNoteSettings(files: Record<string, Uint8Array>): NoteSettings {
|
||||
const bytes = files['word/settings.xml'];
|
||||
if (!bytes) return DEFAULT_NOTE_SETTINGS;
|
||||
const out: NoteSettings = {
|
||||
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote },
|
||||
endnote: { ...DEFAULT_NOTE_SETTINGS.endnote },
|
||||
separator: { ...DEFAULT_NOTE_SETTINGS.separator },
|
||||
};
|
||||
try {
|
||||
const root = parseXml(strFromU8(bytes)).documentElement;
|
||||
for (const kind of ['footnote', 'endnote'] as NoteKind[]) {
|
||||
const el = root.getElementsByTagNameNS(W, `${kind}Pr`)[0];
|
||||
if (!el) continue;
|
||||
const cls = out[kind];
|
||||
const val = (name: string) => { const c = fc(el, name); return c ? wVal(c) : null; };
|
||||
const fmt = NUM_FMT_FROM_DOCX[val('numFmt') ?? ''];
|
||||
if (fmt) cls.numFormat = fmt;
|
||||
const start = Number(val('numStart'));
|
||||
if (Number.isFinite(start) && start > 0) cls.startAt = start;
|
||||
const restart = val('numRestart');
|
||||
if (restart === 'eachPage') cls.restart = 'page';
|
||||
else if (restart === 'eachSect') cls.restart = 'chapter';
|
||||
else if (restart === 'continuous') cls.restart = 'document';
|
||||
if (kind === 'footnote' && val('pos') === 'docEnd') cls.position = 'document';
|
||||
}
|
||||
} catch { /* a malformed settings part just keeps the defaults */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
function docHasEvenOddHeaders(files: Record<string, Uint8Array>): boolean {
|
||||
const bytes = files['word/settings.xml'];
|
||||
if (!bytes) return false;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
|
||||
import { buildDocx } from '../src/lib/export/docx';
|
||||
import { importDocx } from '../src/lib/import/docx';
|
||||
import { EMPTY_HF_SET } from '../src/lib/storage/headerFooter';
|
||||
import { DEFAULT_NOTE_SETTINGS } from '../src/lib/storage/noteSettings';
|
||||
|
||||
type N = any;
|
||||
|
||||
@@ -446,6 +447,49 @@ describe('Leg 1a2c: footnotes and endnotes', () => {
|
||||
check('the body text is untouched', JSON.stringify(res.content).includes('Body one') && JSON.stringify(res.content).includes('and on.'), null);
|
||||
check('no note-removed warning is raised', !res.warnings.some((w: string) => /[Ff]ootnote/.test(w)), res.warnings);
|
||||
});
|
||||
|
||||
it('DOCX: writes word/footnotes.xml + endnotes.xml and reads them back', async () => {
|
||||
const bytes = await buildDocx(notesDoc(), margins, 'portrait');
|
||||
const files = unzipSync(bytes);
|
||||
check('a footnote part is written', !!files['word/footnotes.xml'], Object.keys(files).filter((f) => /note/.test(f)));
|
||||
check('an endnote part is written', !!files['word/endnotes.xml'], Object.keys(files).filter((f) => /note/.test(f)));
|
||||
const doc = strFromU8(files['word/document.xml']);
|
||||
check('the body references both', /<w:footnoteReference w:id="1"/.test(doc) && /<w:endnoteReference w:id="1"/.test(doc), doc.match(/<w:(foot|end)noteReference[^>]*>/g));
|
||||
const fn = strFromU8(files['word/footnotes.xml']);
|
||||
check('the note text lives in its own part', fn.includes('The footnote, with'), fn.slice(0, 400));
|
||||
|
||||
const res = importDocx(bytes);
|
||||
const refs: N[] = [];
|
||||
(function walk(n: N) { if (n.type === 'noteRef') refs.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||||
const section = (res.content.content ?? []).find((n: N) => n.type === 'noteSection');
|
||||
check('DOCX: both anchors come back', refs.length === 2, refs.map((r) => r.attrs));
|
||||
check('DOCX: their classes survive', refs.map((r) => r.attrs.kind).join(',') === 'footnote,endnote', refs.map((r) => r.attrs.kind));
|
||||
// Word's own separator entries carry a w:type and are referenced by nothing.
|
||||
check('DOCX: the separator notes are not imported', (section?.content ?? []).length === 2, section?.content?.map((n: N) => n.attrs));
|
||||
check('DOCX: each anchor points at its own note', refs[0]?.attrs?.id === section?.content?.[0]?.attrs?.id, [refs.map((r) => r.attrs.id), section?.content?.map((n: N) => n.attrs.id)]);
|
||||
check('DOCX: the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200));
|
||||
check('DOCX: bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300));
|
||||
check('DOCX: the body text is untouched', JSON.stringify(res.content).includes('Body one'), null);
|
||||
});
|
||||
|
||||
it('round-trips changed numbering settings through both formats', async () => {
|
||||
const custom = {
|
||||
...DEFAULT_NOTE_SETTINGS,
|
||||
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, numFormat: 'A' as const, startAt: 4, restart: 'page' as const },
|
||||
separator: { ...DEFAULT_NOTE_SETTINGS.separator, relWidthPercent: 60, weightPt: 1.5, align: 'center' as const },
|
||||
};
|
||||
const odt = await buildOdt(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, custom);
|
||||
const back = importOdt(odt).notes;
|
||||
check('ODF: format, start and restart survive',
|
||||
back.footnote.numFormat === 'A' && back.footnote.startAt === 4 && back.footnote.restart === 'page', back.footnote);
|
||||
check('ODF: the separator survives',
|
||||
back.separator.relWidthPercent === 60 && Math.abs(back.separator.weightPt - 1.5) < 0.05 && back.separator.align === 'center', back.separator);
|
||||
|
||||
const docx = await buildDocx(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, false, custom);
|
||||
const dback = importDocx(docx).notes;
|
||||
check('DOCX: format, start and restart survive',
|
||||
dback.footnote.numFormat === 'A' && dback.footnote.startAt === 4 && dback.footnote.restart === 'page', dback.footnote);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 1a3: continued ordered-list start value', () => {
|
||||
|
||||
Reference in new issue
Block a user