From 30223d330527630c17b610ea46fe4c0a065f45c8 Mon Sep 17 00:00:00 2001 From: Haakam Aujla Date: Sun, 20 Sep 2026 01:52:36 +0000 Subject: [PATCH] Give the OOXML part names and content-types namespace one home each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure de-duplication found while inventorying the remaining constants. No value changes: XLSX_LEADING_ENTRIES, both magic Buffers and every part name resolve to byte-identical strings at runtime, compared against main by evaluating both modules. That equality is the point of the commit rather than a side effect — the manifest's exact spelling and order is what keeps exceljs off the temp-file spool branch, so it is verified rather than assumed. - 'word/document.xml' and 'xl/workbook.xml' each appeared three times: as a Buffer for the raw-byte scan, inline in ooxmlKind's exact-match path, and again later as DOCX_MAIN_PART / WORKBOOK_PART. The string is now canonical and the Buffer derives from it, so the two routing paths cannot come to disagree about which part names a package. - WORKBOOK_RELS_PART and CONTENT_TYPES_PART moved up beside them, and XLSX_LEADING_ENTRIES now references all three instead of respelling them. The rebuild orders exactly the parts the resolver reads; spelling one differently in the manifest would reintroduce the branch the rebuild exists to avoid. - CONTENT_TYPES_NS was declared locally inside each of the two scans of [Content_Types].xml. They must agree on the namespace they filter by, so it is hoisted to module scope next to PACKAGE_RELS_NS, which it is easily confused with — the parts have distinct OPC namespaces and the comment now says so. Each literal now occurs exactly once in the file. Co-Authored-By: Claude Opus 5 (1M context) --- attachment.ts | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/attachment.ts b/attachment.ts index 335260d..674ea88 100644 --- a/attachment.ts +++ b/attachment.ts @@ -1157,9 +1157,20 @@ const RTF_MAGIC = Buffer.from('{\\rtf') // Legacy OLE (.doc). Shared with .xls/.ppt/.msg, so we only claim doc when the extension confirms it. const OLE_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) -// docx and xlsx share the zip magic; their main part is what tells them apart. -const DOCX_PART = Buffer.from('word/document.xml') -const XLSX_PART = Buffer.from('xl/workbook.xml') +// docx and xlsx share the zip magic; their main part is what tells them apart. The names are needed +// in two shapes — as entry-name strings for the exact-match path, and as bytes for the raw scan the +// fallback uses — so the string is canonical here and the Buffer is derived from it. Both shapes +// have to name the SAME part or the two paths disagree about what a package is. +const DOCX_MAIN_PART = 'word/document.xml' +const WORKBOOK_PART = 'xl/workbook.xml' +const DOCX_PART = Buffer.from(DOCX_MAIN_PART) +const XLSX_PART = Buffer.from(WORKBOOK_PART) +// The other OPC part names this module names by hand, kept with the two above so there is a single +// home for them: routing, the streaming rebuild and the sheet-identity resolver all spell the same +// parts, and a rebuild that ordered a part the resolver reads under a different spelling would put +// exceljs back on the branch the rebuild exists to avoid. +const WORKBOOK_RELS_PART = 'xl/_rels/workbook.xml.rels' +const CONTENT_TYPES_PART = '[Content_Types].xml' const startsWith = (content: Buffer, magic: Buffer): boolean => content.length >= magic.length && content.subarray(0, magic.length).equals(magic) @@ -1172,8 +1183,8 @@ const ooxmlKind = (content: Buffer): HandlerKind | undefined => { const names = zipEntryNames(content) if (names) { const folded = new Set(names.flatMap((name) => opcKey(name) ?? [])) - const hasDocx = folded.has('word/document.xml') - const hasXlsx = folded.has('xl/workbook.xml') + const hasDocx = folded.has(DOCX_MAIN_PART) + const hasXlsx = folded.has(WORKBOOK_PART) if (hasDocx) return 'docx' // a real word/document.xml root part wins (docx may embed a workbook) if (hasXlsx) return 'xlsx' return undefined // OOXML zip with neither root part (pptx, jar, plain archive) @@ -1481,10 +1492,10 @@ const checkDecompressionBudget = async (buf: Buffer, cap: number): Promise this.model (the 'sheets' TypeError without it) - 'xl/_rels/workbook.xml.rels', // -> this.workbookRels + WORKBOOK_PART, // -> this.model (the 'sheets' TypeError without it) + WORKBOOK_RELS_PART, // -> this.workbookRels 'xl/sharedStrings.xml', // -> this.sharedStrings 'xl/styles.xml', // -> this.styles (number formats) ] @@ -1589,9 +1600,8 @@ const EXCELJS_WORKSHEET_DISPATCH = /xl\/worksheets\/sheet\d+[.]xml/ // The workbook is the ORDERING and NAMING authority only. MEMBERSHIP stays the archive's: see the // tail of workbookWorksheets for why handing it that third job silently deletes text. -const WORKBOOK_PART = 'xl/workbook.xml' -const WORKBOOK_RELS_PART = 'xl/_rels/workbook.xml.rels' -const CONTENT_TYPES_PART = '[Content_Types].xml' +// WORKBOOK_PART, WORKBOOK_RELS_PART and CONTENT_TYPES_PART are declared with the format magic +// above, where routing needs them first. // The relationship types naming a sheet that has NO xl/worksheets part, mapped to the part family // each one must live in, in both flavours: Transitional (schemas.openxmlformats.org) and Strict @@ -1700,6 +1710,10 @@ const OFFICE_RELS_NS = new Set([ // part markup, not in the package. Relationship TYPE values do move, so NON_WORKSHEET_REL lists both // spellings of each one explicitly. const PACKAGE_RELS_NS = 'http://schemas.openxmlformats.org/package/2006/relationships' +// The content-types part has its own OPC namespace, distinct from the relationships one above. Read +// by two separate scans of [Content_Types].xml — one resolving worksheet parts, one classifying +// them — which each used to declare it locally; they must agree, so there is one of it. +const CONTENT_TYPES_NS = 'http://schemas.openxmlformats.org/package/2006/content-types' // One relationship as the workbook points at it. The TYPE is carried, not just the target: a Target // is a string the producer chose, so classifying a declaration by the shape of its path let a @@ -1759,7 +1773,6 @@ const parseWorkbookParts = async (workbookXml: string, relsXml: string, contentT const overriddenParts = new Set() let contentTypesKnown = false if (contentTypesXml !== undefined) { - const CONTENT_TYPES_NS = 'http://schemas.openxmlformats.org/package/2006/content-types' const contentTypes = new SaxesParser({ xmlns: true, resolvePrefix: () => UNBOUND_NAMESPACE, @@ -1851,7 +1864,6 @@ const metadataFallbackWorksheets = async ( } if (contentTypesXml !== undefined) { - const CONTENT_TYPES_NS = 'http://schemas.openxmlformats.org/package/2006/content-types' const contentTypes = new SaxesParser({ xmlns: true, resolvePrefix: () => UNBOUND_NAMESPACE, @@ -2270,7 +2282,7 @@ const reorderForStreaming = async (buf: Buffer): Promise => { // Fidelity is pinned by tests/docx-fidelity.test.ts against mammoth itself over a real Word corpus. // The deliberate divergences are listed at DOCX_CONTAINERS. -const DOCX_MAIN_PART = 'word/document.xml' +// DOCX_MAIN_PART is declared with the format magic above, where routing needs it first. // Namespace URI -> the prefix mammoth's element names carry (office-xml-reader.js:10-36). Matching a // literal `w:` prefix instead would be a shippable bug three times over: the ISO-strict format binds