fix: schema-valid ODF and DOCX output
ODF: every part declares 1.3 (the version LibreOffice writes; header-first is a 1.3 element), draw:image gains the mandatory xlink:type trio, text:time-value becomes an xsd dateTime (import still reads the legacy duration), index-entry-link-start/-end stay in TOC templates only, and dont-balance-text-columns moves onto style:section-properties (probed: LO reads and re-writes it there). DOCX: a final pass re-sorts w:settings into the CT_Settings sequence, and comments.xml declares w14 mc:Ignorable.
This commit is contained in:
1 parent
c566950258
commit
3a6bc336f7
6 files changed
+103
-29
No files matched your search
@@ -52,6 +52,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
|
||||
- **`collapseRunWhitespace`** — strips the bare `\n` separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word).
|
||||
- **`rewriteStylesXml`** — `style:page-usage="mirrored"` on the page layout when the margins mirror (the left/right pair is already the inner/outer one), default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's `fo:margin-bottom` → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below).
|
||||
- **`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; `styleXmlComponent` unwraps `ImportedXmlComponent.fromXmlString`'s nameless wrapper, which would serialize as a literal `<undefined>` element.
|
||||
|
||||
The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`.
|
||||
|
||||
|
||||
+63
-3
@@ -1524,6 +1524,12 @@ function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array {
|
||||
|
||||
const paraId = (id: number) => (0x10000000 + id).toString(16).toUpperCase().padStart(8, '0');
|
||||
let xml = comments.replace(/<w:comments\b(?![^>]*xmlns:w14=)/, `<w:comments xmlns:w14="${W14_NS}"`);
|
||||
// w14:paraId needs w14 declared mc:Ignorable, or a strict reader rejects the part.
|
||||
xml = xml.replace(/<w:comments\b(?![^>]*xmlns:mc=)/,
|
||||
'<w:comments xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"');
|
||||
xml = /<w:comments\b[^>]*\bmc:Ignorable="/.test(xml)
|
||||
? xml.replace(/(<w:comments\b[^>]*\bmc:Ignorable=")((?:(?!w14\b)[^"])*")/, '$1w14 $2')
|
||||
: xml.replace(/<w:comments\b/, '<w:comments mc:Ignorable="w14"');
|
||||
for (const c of resolved) {
|
||||
// Stamp the comment's last body paragraph (Word keys the whole comment off it);
|
||||
// the greedy tempered scan ends at the last <w:p before </w:comment>.
|
||||
@@ -1548,6 +1554,60 @@ function applyCommentsResolvedDocx(bytes: Uint8Array): Uint8Array {
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// CT_Settings fixes its children's sequence (extracted from wml.xsd); several passes
|
||||
// prepend into word/settings.xml, so the final pass re-sorts the part into this order.
|
||||
const SETTINGS_ORDER = ['writeProtection', 'view', 'zoom', 'removePersonalInformation',
|
||||
'removeDateAndTime', 'doNotDisplayPageBoundaries', 'displayBackgroundShape',
|
||||
'printPostScriptOverText', 'printFractionalCharacterWidth', 'printFormsData',
|
||||
'embedTrueTypeFonts', 'embedSystemFonts', 'saveSubsetFonts', 'saveFormsData',
|
||||
'mirrorMargins', 'alignBordersAndEdges', 'bordersDoNotSurroundHeader',
|
||||
'bordersDoNotSurroundFooter', 'gutterAtTop', 'hideSpellingErrors',
|
||||
'hideGrammaticalErrors', 'activeWritingStyle', 'proofState', 'formsDesign',
|
||||
'attachedTemplate', 'linkStyles', 'stylePaneFormatFilter', 'stylePaneSortMethod',
|
||||
'documentType', 'mailMerge', 'revisionView', 'trackRevisions', 'doNotTrackMoves',
|
||||
'doNotTrackFormatting', 'documentProtection', 'autoFormatOverride', 'styleLockTheme',
|
||||
'styleLockQFSet', 'defaultTabStop', 'autoHyphenation', 'consecutiveHyphenLimit',
|
||||
'hyphenationZone', 'doNotHyphenateCaps', 'showEnvelope', 'summaryLength',
|
||||
'clickAndTypeStyle', 'defaultTableStyle', 'evenAndOddHeaders', 'bookFoldRevPrinting',
|
||||
'bookFoldPrinting', 'bookFoldPrintingSheets', 'drawingGridHorizontalSpacing',
|
||||
'drawingGridVerticalSpacing', 'displayHorizontalDrawingGridEvery',
|
||||
'displayVerticalDrawingGridEvery', 'doNotUseMarginsForDrawingGridOrigin',
|
||||
'drawingGridHorizontalOrigin', 'drawingGridVerticalOrigin', 'doNotShadeFormData',
|
||||
'noPunctuationKerning', 'characterSpacingControl', 'printTwoOnOne',
|
||||
'strictFirstAndLastChars', 'noLineBreaksAfter', 'noLineBreaksBefore',
|
||||
'savePreviewPicture', 'doNotValidateAgainstSchema', 'saveInvalidXml',
|
||||
'ignoreMixedContent', 'alwaysShowPlaceholderText', 'doNotDemarcateInvalidXml',
|
||||
'saveXmlDataOnly', 'useXSLTWhenSaving', 'saveThroughXslt', 'showXMLTags',
|
||||
'alwaysMergeEmptyNamespace', 'updateFields', 'hdrShapeDefaults', 'footnotePr',
|
||||
'endnotePr', 'compat', 'docVars', 'rsids', 'mathPr', 'attachedSchema',
|
||||
'themeFontLang', 'clrSchemeMapping', 'doNotIncludeSubdocsInStats',
|
||||
'doNotAutoCompressPictures', 'forceUpgrade', 'captions', 'readModeInkLockDown',
|
||||
'smartTagType', 'shapeDefaults', 'doNotEmbedSmartTags', 'decimalSymbol',
|
||||
'listSeparator'];
|
||||
|
||||
// Post-pack pass (runs last): sort w:settings children into the schema sequence.
|
||||
function orderDocxSettings(bytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(bytes);
|
||||
const setBytes = files['word/settings.xml'];
|
||||
if (!setBytes) return bytes;
|
||||
const xml = strFromU8(setBytes);
|
||||
const m = /(<w:settings\b[^>]*>)([\s\S]*)(<\/w:settings>)/.exec(xml);
|
||||
if (!m) return bytes;
|
||||
const children = m[2].match(/<((?:w|m):\w+)\b(?:[^>]*\/>|[^>]*>[\s\S]*?<\/\1>)/g) ?? [];
|
||||
// Only reorder what the tokenizer accounted for completely.
|
||||
if (children.join('') !== m[2]) return bytes;
|
||||
const rank = (c: string) => {
|
||||
const i = SETTINGS_ORDER.indexOf(/^<[wm]+:(\w+)/.exec(c)?.[1] ?? '');
|
||||
return i < 0 ? SETTINGS_ORDER.length : i;
|
||||
};
|
||||
const sorted = [...children].sort((a, b) => rank(a) - rank(b));
|
||||
if (sorted.join('') === m[2]) return bytes;
|
||||
files['word/settings.xml'] = strToU8(xml.replace(m[0], m[1] + sorted.join('') + m[3]));
|
||||
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: Word's mirror margins are a document setting (w:mirrorMargins in
|
||||
// word/settings.xml); the docx lib writes only the per-section w:pgMar, where left and
|
||||
// right are already the inner/outer pair the editor holds.
|
||||
@@ -2566,8 +2626,8 @@ export async function buildDocx(
|
||||
const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored);
|
||||
const dims = pageDimsCm(pageFormat, orientation);
|
||||
const marked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10);
|
||||
if (isEmptyPageDecor(decor)) return marked;
|
||||
if (isEmptyPageDecor(decor)) return orderDocxSettings(marked);
|
||||
const pt = (cm: number) => (cm / 2.54) * 72;
|
||||
return applyPageDecorDocx(marked, decor,
|
||||
pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom));
|
||||
return orderDocxSettings(applyPageDecorDocx(marked, decor,
|
||||
pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom)));
|
||||
}
|
||||
+28
-11
@@ -36,7 +36,7 @@ import { citationText, isBibType } from '../editor/extensions/bibliographyEntry'
|
||||
import { isCitationStyle, rowTemplate, type CitationStyle } from '../utils/citationStyle';
|
||||
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { CELL_FORMAT_SPECS, currencyParts, datePattern, isCellFormat, type CellFormat } from '../utils/cellFormat';
|
||||
import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
|
||||
import { findFormat, renderFormat, odfNumberStyle, toDateValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
|
||||
import { parseLatex } from '../math/latex';
|
||||
import { mathmlDocument } from '../math/mathml';
|
||||
|
||||
@@ -4015,7 +4015,7 @@ function imageFrameXml(img: ImageExport, index: number): string {
|
||||
(img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') +
|
||||
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
|
||||
const title = img.alt ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
|
||||
const inner = `<draw:image xlink:href="${img.path}"/>${title}`;
|
||||
const inner = `<draw:image xlink:href="${img.path}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>${title}`;
|
||||
const floats = img.anchorPage != null || img.wrap !== 'inline';
|
||||
const anchor = img.anchorPage != null
|
||||
? ` text:anchor-type="page" text:anchor-page-number="${img.anchorPage}"`
|
||||
@@ -4047,7 +4047,7 @@ function hfImageFrameXml(img: ImageExport, index: number): string {
|
||||
` svg:x="${img.wrapOffsetCm ?? 0}cm" svg:y="${img.wrapOffsetYCm ?? 0}cm"`;
|
||||
return (
|
||||
`<draw:frame draw:name="HfImage${index + 1}" ${anchor} draw:z-index="${index}"${dims}>` +
|
||||
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
|
||||
`<draw:image xlink:href="${img.path}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>${title}</draw:frame>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4211,7 +4211,9 @@ function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[]
|
||||
const styleName = styleFor(fmt);
|
||||
const fixedAttr = ` text:fixed="${field.fixed ? 'true' : 'false'}"`;
|
||||
if (fmt.kind === 'time') {
|
||||
return `<text:time text:time-value="${toTimeValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:time>`;
|
||||
// text:time-value is xsd:time/dateTime — LibreOffice writes the full dateTime;
|
||||
// a PT…S duration here is a schema violation.
|
||||
return `<text:time text:time-value="${toDateValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:time>`;
|
||||
}
|
||||
return `<text:date text:date-value="${toDateValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:date>`;
|
||||
});
|
||||
@@ -4377,13 +4379,13 @@ function applyTextBoxes(odtBytes: Uint8Array, boxes: TextBoxExport[]): Uint8Arra
|
||||
}
|
||||
|
||||
// Section style for a multi-column region: balanced columns with a uniform gap.
|
||||
// text:dont-balance-text-columns sits on <style:columns> (not section-properties).
|
||||
// text:dont-balance-text-columns sits on <style:section-properties> — the only place
|
||||
// the schema admits it, and where LibreOffice reads and re-writes it (probed).
|
||||
function columnsSectionStyle(cols: ColumnsExport, index: number): string {
|
||||
return (
|
||||
`<style:style style:name="ColSec${index + 1}" style:family="section">` +
|
||||
`<style:section-properties style:editable="false">` +
|
||||
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"` +
|
||||
` text:dont-balance-text-columns="false"/>` +
|
||||
`<style:section-properties style:editable="false" text:dont-balance-text-columns="false">` +
|
||||
`<style:columns fo:column-count="${cols.count}" fo:column-gap="${cols.gapCm}cm"/>` +
|
||||
`</style:section-properties></style:style>`
|
||||
);
|
||||
}
|
||||
@@ -4524,10 +4526,13 @@ function tocXml(toc: TocExport, index: number, bibTypes: string[]): string {
|
||||
// An index with no page numbers has nothing to lead to either, so the stop goes with
|
||||
// them — that pair is what LibreOffice's own dialog switches off together.
|
||||
const pageCol = toc.pageNumbers ? `${stop}<text:index-entry-page-number/>` : '';
|
||||
// index-entry-link-start/-end are admitted in table-of-content templates only —
|
||||
// in the figure/table/alphabetical templates they are a schema violation.
|
||||
const links = toc.kind === 'toc';
|
||||
const entry = (levelAttr: string, style: string) =>
|
||||
`<text:${spec.el}-entry-template${levelAttr} text:style-name="${style}">` +
|
||||
`<text:index-entry-link-start/><text:index-entry-text/>${pageCol}` +
|
||||
`<text:index-entry-link-end/>` +
|
||||
`${links ? '<text:index-entry-link-start/>' : ''}<text:index-entry-text/>${pageCol}` +
|
||||
`${links ? '<text:index-entry-link-end/>' : ''}` +
|
||||
`</text:${spec.el}-entry-template>`;
|
||||
// An alphabetical index is fed by its marks, is single-level, and merges the pages of
|
||||
// a term the reader marked more than once — LibreOffice's text:combine-entries.
|
||||
@@ -5010,7 +5015,19 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// Sections past the first get their own master page, which is where ODF keeps a
|
||||
// section's header/footer; the SEC-marked block points at it.
|
||||
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation);
|
||||
return zipFinal(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props));
|
||||
return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props)));
|
||||
}
|
||||
|
||||
// The package declares ODF 1.3 in every part — the version LibreOffice writes, and
|
||||
// the first with style:header-first/-footer-first. odf-kit stamps 1.2.
|
||||
function applyOdfVersion(odtBytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
for (const name of ['content.xml', 'styles.xml', 'meta.xml', 'settings.xml', 'META-INF/manifest.xml']) {
|
||||
const bytes = files[name];
|
||||
if (!bytes) continue;
|
||||
files[name] = strToU8(strFromU8(bytes).replace(/((?:office|manifest):version=")1\.2(")/, '$11.3$2'));
|
||||
}
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// The document's first page number. ODF has no document-level start: LibreOffice puts
|
||||
|
||||
+9
-10
@@ -1872,18 +1872,17 @@ function parseNumberStyleTokens(styleEl: Element): Token[] {
|
||||
return toks;
|
||||
}
|
||||
|
||||
// ISO local datetime for the node's `value`: a date-value is kept; a time-value
|
||||
// (PThHmMsS) is placed on today's date (only its time part is rendered).
|
||||
// ISO local datetime for the node's `value`: a date-value or dateTime time-value is
|
||||
// kept; a legacy PThHmMsS time-value is placed on today's date (only its time renders).
|
||||
function fieldValue(kind: 'date' | 'time', raw: string | null): string {
|
||||
if (kind === 'date') {
|
||||
const d = raw ? new Date(raw) : null;
|
||||
return d && !isNaN(d.getTime()) ? raw! : '';
|
||||
const m = kind === 'time' && raw ? /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(raw) : null;
|
||||
if (m) {
|
||||
const now = new Date();
|
||||
now.setHours(Number(m[1] ?? 0), Number(m[2] ?? 0), Number(m[3] ?? 0), 0);
|
||||
return toDateValue(now);
|
||||
}
|
||||
const m = raw ? /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(raw) : null;
|
||||
if (!m) return '';
|
||||
const now = new Date();
|
||||
now.setHours(Number(m[1] ?? 0), Number(m[2] ?? 0), Number(m[3] ?? 0), 0);
|
||||
return toDateValue(now);
|
||||
const d = raw ? new Date(raw) : null;
|
||||
return d && !isNaN(d.getTime()) ? raw! : '';
|
||||
}
|
||||
|
||||
// A <text:date>/<text:time> whose data style matches a known format → a live field
|
||||
|
||||
@@ -112,10 +112,6 @@ export function toDateValue(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function toTimeValue(d: Date): string {
|
||||
return `PT${pad2(d.getHours())}H${pad2(d.getMinutes())}M${pad2(d.getSeconds())}S`;
|
||||
}
|
||||
|
||||
// --- ODF number style --------------------------------------------------------
|
||||
|
||||
function escNumberText(s: string): string {
|
||||
|
||||
@@ -1527,7 +1527,8 @@ describe('Leg 10: date/time fields (text:date / text:time)', () => {
|
||||
check('fixed date carries text:fixed=true', /<text:date[^>]*text:fixed="true"/.test(content));
|
||||
check('auto date carries text:fixed=false', /<text:date[^>]*text:fixed="false"/.test(content));
|
||||
check('date-value present', content.includes('text:date-value="2026-07-08T14:30:45"'));
|
||||
check('time-value present', content.includes('text:time-value="PT14H30M45S"'));
|
||||
// xsd dateTime, as LibreOffice writes it — a PT…S duration is a schema violation.
|
||||
check('time-value present', content.includes('text:time-value="2026-07-08T14:30:45"'));
|
||||
check('mints a number:date-style', content.includes('<number:date-style '));
|
||||
check('mints a number:time-style', content.includes('<number:time-style '));
|
||||
check('declares number namespace', content.includes('xmlns:number='));
|
||||
|
||||
Reference in new issue
Block a user