Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: docs
> ./packages/core/src/docx/rezip/packaging.ts
/**
* Header/Footer & Comment Packaging
*
* Serialize modified headers, footers, and comments back into the ZIP and
* ensure each new part is registered in `[Content_Types].xml` and the
* document's rels file. Without this step Word silently drops parts that
* the editor inserted into a previously-blank document (#274).
*/
import type JSZip from 'jszip';
import type { Document } from '../../types/document';
import type { HeaderFooter } from '../../types/content';
import { serializeHeaderFooter } from '../serializer/headerFooterSerializer';
import {
serializeCommentsWithInfo,
serializeCommentsExtended,
serializeCommentsIds,
serializeCommentsExtensible,
} from '../serializer/commentSerializer';
import { serializeFootnotes, serializeEndnotes } from '../serializer/noteSerializer';
import { RELATIONSHIP_TYPES } from '../relsParser';
import { findMaxRId, readRelsOrStub, headerFooterFilename } from './parts';
const HEADER_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';
const FOOTER_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml';
export const COMMENTS_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml';
export const COMMENTS_EXTENDED_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml';
export const COMMENTS_IDS_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml';
export const COMMENTS_EXTENSIBLE_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml';
/**
* Ensure every header/footer in `doc.package.relationships` is wired up in
* `[Content_Types].xml` and `word/_rels/document.xml.rels`. For blank documents
* where the user adds a header/footer for the first time, these files don't
* know about the new part yet — without this Word silently drops them (#274).
*/
export async function ensureHeaderFooterParts(
doc: Document,
zip: JSZip,
compressionLevel: number
): Promise<void> {
const rels = doc.package.relationships;
if (!rels) return;
const parts: Array<{ rId: string; target: string; contentType: string; relType: string }> = [];
for (const [rId, rel] of rels) {
if (!rel.target) continue;
const contentType =
rel.type === RELATIONSHIP_TYPES.header
? HEADER_CONTENT_TYPE
: rel.type === RELATIONSHIP_TYPES.footer
? FOOTER_CONTENT_TYPE
: null;
if (!contentType) continue;
parts.push({
rId,
target: rel.target.replace(/^(\/?word\/)/, ''),
contentType,
relType: rel.type,
});
}
if (parts.length === 0) return;
const ctFile = zip.file('[Content_Types].xml');
if (ctFile) {
let ctXml = await ctFile.async('text');
let changed = false;
for (const { target, contentType } of parts) {
const partName = `/word/${target}`;
if (!ctXml.includes(`PartName="${partName}"`)) {
ctXml = ctXml.replace(
'</Types>',
`<Override PartName="${partName}" ContentType="${contentType}"/></Types>`
);
changed = true;
}
}
if (changed) {
zip.file('[Content_Types].xml', ctXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
}
const relsPath = 'word/_rels/document.xml.rels';
let relsXml = await readRelsOrStub(zip, relsPath);
let relsChanged = false;
for (const { rId, relType, target } of parts) {
if (!relsXml.includes(`Id="${rId}"`)) {
relsXml = relsXml.replace(
'</Relationships>',
`<Relationship Id="${rId}" Type="${relType}" Target="${target}"/></Relationships>`
);
relsChanged = true;
}
}
if (relsChanged) {
zip.file(relsPath, relsXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
}
/**
* Ensure content types and relationships exist for all comment parts.
* Reads each shared file once, applies all modifications, writes once.
*/
async function ensureAllCommentParts(zip: JSZip, compressionLevel: number): Promise<void> {
const COMMENT_PARTS = [
{
partName: '/word/comments.xml',
contentType: COMMENTS_CONTENT_TYPE,
target: 'comments.xml',
relType: RELATIONSHIP_TYPES.comments,
},
{
partName: '/word/commentsExtended.xml',
contentType: COMMENTS_EXTENDED_CONTENT_TYPE,
target: 'commentsExtended.xml',
relType: RELATIONSHIP_TYPES.commentsExtended,
},
{
partName: '/word/commentsIds.xml',
contentType: COMMENTS_IDS_CONTENT_TYPE,
target: 'commentsIds.xml',
relType: RELATIONSHIP_TYPES.commentsIds,
},
{
partName: '/word/commentsExtensible.xml',
contentType: COMMENTS_EXTENSIBLE_CONTENT_TYPE,
target: 'commentsExtensible.xml',
relType: RELATIONSHIP_TYPES.commentsExtensible,
},
];
// Content types — single read/write
const ctFile = zip.file('[Content_Types].xml');
if (ctFile) {
let ctXml = await ctFile.async('text');
let changed = false;
for (const { partName, contentType } of COMMENT_PARTS) {
if (!ctXml.includes(partName)) {
ctXml = ctXml.replace(
'</Types>',
`<Override PartName="${partName}" ContentType="${contentType}"/></Types>`
);
changed = true;
}
}
if (changed) {
zip.file('[Content_Types].xml', ctXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
}
// Relationships — single read/write
const relsPath = 'word/_rels/document.xml.rels';
const relsFile = zip.file(relsPath);
if (relsFile) {
let relsXml = await relsFile.async('text');
let changed = false;
for (const { target, relType } of COMMENT_PARTS) {
if (!relsXml.includes(target)) {
const newRId = `rId${findMaxRId(relsXml) + 1}`;
relsXml = relsXml.replace(
'</Relationships>',
`<Relationship Id="${newRId}" Type="${relType}" Target="${target}"/></Relationships>`
);
changed = true;
}
}
if (changed) {
zip.file(relsPath, relsXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
}
}
/**
* Serialize all comment-side XML parts (comments + extended + ids + extensible)
* into the ZIP and register them in content types and rels.
*/
export async function serializeCommentsToZip(
doc: Document,
zip: JSZip,
compressionLevel: number
): Promise<void> {
const comments = doc.package.document.comments;
if (!comments || comments.length === 0) return;
const { xml: commentsXml, paraInfos } = serializeCommentsWithInfo(comments);
zip.file('word/comments.xml', commentsXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
// Write commentsExtended.xml for reply threading (Word/Google Docs interop)
const extendedXml = serializeCommentsExtended(paraInfos);
if (extendedXml) {
zip.file('word/commentsExtended.xml', extendedXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
// Write commentsIds.xml for stable IDs (Word Online needs this for replies)
const idsXml = serializeCommentsIds(paraInfos);
if (idsXml) {
zip.file('word/commentsIds.xml', idsXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
// Write commentsExtensible.xml for UTC dates (Pages, Word 2016+)
const extensibleXml = serializeCommentsExtensible(paraInfos, comments);
if (extensibleXml) {
zip.file('word/commentsExtensible.xml', extensibleXml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
await ensureAllCommentParts(zip, compressionLevel);
}
/**
* Serialize footnotes into `word/footnotes.xml`.
*
* Re-emits separator notes (kept in `footnoteSeparators`) ahead of the normal
* notes, mirroring Word's ordering. Only writes when the document actually has
* footnotes; otherwise the original part is left untouched. Content-type / rels
* registration is skipped on purpose — a document that carries footnotes
* already declares the part, and notes-from-scratch is out of scope here.
*/
export function serializeFootnotesToZip(doc: Document, zip: JSZip, compressionLevel: number): void {
const normal = doc.package.footnotes ?? [];
const separators = doc.package.footnoteSeparators ?? [];
if (normal.length === 0 && separators.length === 0) return;
const xml = serializeFootnotes([...separators, ...normal]);
zip.file('word/footnotes.xml', xml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
/**
* Serialize endnotes into `word/endnotes.xml`. See {@link serializeFootnotesToZip}.
*/
export function serializeEndnotesToZip(doc: Document, zip: JSZip, compressionLevel: number): void {
const normal = doc.package.endnotes ?? [];
const separators = doc.package.endnoteSeparators ?? [];
if (normal.length === 0 && separators.length === 0) return;
const xml = serializeEndnotes([...separators, ...normal]);
zip.file('word/endnotes.xml', xml, {
compression: 'DEFLATE',
compressionOptions: { level: compressionLevel },
});
}
/**
* Collect serialized header/footer XML updates from the document model.
* Uses the relationship map to resolve rId → filename.
*/
export function collectHeaderFooterUpdates(doc: Document): Map<string, string> {
const updates = new Map<string, string>();
const rels = doc.package.relationships;
if (!rels) return updates;
const parts: Array<{
map: Map<string, HeaderFooter> | undefined;
type: string;
}> = [
{ map: doc.package.headers, type: RELATIONSHIP_TYPES.header },
{ map: doc.package.footers, type: RELATIONSHIP_TYPES.footer },
];
for (const { map, type } of parts) {
if (!map) continue;
for (const [rId, headerFooter] of map.entries()) {
const rel = rels.get(rId);
if (rel && rel.type === type && rel.target) {
updates.set(headerFooterFilename(rel.target), serializeHeaderFooter(headerFooter));
}
}
}
return updates;
}
/**
* Serialize modified headers and footers into the ZIP.
*/
export function serializeHeadersFootersToZip(
doc: Document,
zip: JSZip,
compressionLevel: number
): void {
const compressionOptions = { level: compressionLevel };
for (const [filename, xml] of collectHeaderFooterUpdates(doc)) {
zip.file(filename, xml, { compression: 'DEFLATE', compressionOptions });
}
}