255 lines
9.0 KiB
TypeScript
255 lines
9.0 KiB
TypeScript
import type { DocPageHeaderFooter, DocPageSetup } from "@/lib/drive/doc-page-setup"
|
|
import type { TipTapJSON } from "@/lib/drive/richtext-import"
|
|
import { prepareTablesForDocxExport } from "@/lib/drive/docs-table-export"
|
|
|
|
type DocxArchive = Record<string, Uint8Array>
|
|
|
|
const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
const R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
const REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
const CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
|
|
|
function decodeXml(bytes: Uint8Array | undefined): string {
|
|
if (!bytes) return ""
|
|
return new TextDecoder().decode(bytes)
|
|
}
|
|
|
|
function encodeXml(text: string): Uint8Array {
|
|
return new TextEncoder().encode(text)
|
|
}
|
|
|
|
function escapeXml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
}
|
|
|
|
async function tipTapToBodyXml(content: TipTapJSON): Promise<string> {
|
|
try {
|
|
const { generateDOCX } = await import("@docen/export-docx")
|
|
const prepared = prepareTablesForDocxExport(content)
|
|
const buf = await generateDOCX(prepared, { outputType: "uint8array" })
|
|
const { unzipSync } = await import("fflate")
|
|
const archive = unzipSync(buf as Uint8Array) as DocxArchive
|
|
const docXml = decodeXml(archive["word/document.xml"])
|
|
const bodyMatch = docXml.match(/<w:body>([\s\S]*?)<\/w:body>/i)
|
|
if (bodyMatch?.[1]) {
|
|
return bodyMatch[1]
|
|
.replace(/<w:sectPr\b[\s\S]*?<\/w:sectPr>/gi, "")
|
|
.replace(/<w:sectPr\b[^>]*\/>/gi, "")
|
|
}
|
|
} catch {
|
|
/* fallback below */
|
|
}
|
|
|
|
const paragraphs: string[] = []
|
|
const walk = (node: unknown) => {
|
|
if (!node || typeof node !== "object") return
|
|
const record = node as TipTapJSON
|
|
if (record.type === "paragraph" || record.type === "heading") {
|
|
const texts: string[] = []
|
|
const visit = (n: unknown) => {
|
|
if (!n || typeof n !== "object") return
|
|
const r = n as TipTapJSON
|
|
if (r.type === "text" && typeof r.text === "string") texts.push(escapeXml(r.text))
|
|
if (Array.isArray(r.content)) r.content.forEach(visit)
|
|
}
|
|
if (Array.isArray(record.content)) record.content.forEach(visit)
|
|
paragraphs.push(
|
|
`<w:p><w:r><w:t xml:space="preserve">${texts.join("")}</w:t></w:r></w:p>`
|
|
)
|
|
}
|
|
if (Array.isArray(record.content)) record.content.forEach(walk)
|
|
}
|
|
walk(content)
|
|
return paragraphs.join("")
|
|
}
|
|
|
|
function wrapHeaderFooterPart(bodyInner: string, kind: "hdr" | "ftr"): string {
|
|
const tag = kind === "hdr" ? "header" : "footer"
|
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:${tag} xmlns:w="${W_NS}" xmlns:r="${R_NS}">${bodyInner}</w:${tag}>`
|
|
}
|
|
|
|
async function regionToPartXml(
|
|
region: DocPageHeaderFooter | null | undefined,
|
|
kind: "hdr" | "ftr",
|
|
pageNumbers?: DocPageSetup["pageNumbers"]
|
|
): Promise<string | null> {
|
|
if (!region?.content) return null
|
|
let bodyInner = await tipTapToBodyXml(region.content as TipTapJSON)
|
|
|
|
if (
|
|
pageNumbers?.enabled &&
|
|
pageNumbers.placement === (kind === "hdr" ? "header" : "footer")
|
|
) {
|
|
const align =
|
|
pageNumbers.align === "center"
|
|
? "center"
|
|
: pageNumbers.align === "right"
|
|
? "right"
|
|
: "left"
|
|
const pageField = `<w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple>`
|
|
bodyInner += `<w:p><w:pPr><w:jc w:val="${align}"/></w:pPr><w:r>${pageField}</w:r></w:p>`
|
|
}
|
|
|
|
if (!bodyInner.trim()) return null
|
|
return wrapHeaderFooterPart(bodyInner, kind)
|
|
}
|
|
|
|
function addRelationship(relsXml: string, id: string, type: string, target: string): string {
|
|
if (relsXml.includes(`Id="${id}"`)) return relsXml
|
|
const insert = `<Relationship Id="${id}" Type="${type}" Target="${target}"/>`
|
|
return relsXml.replace("</Relationships>", `${insert}</Relationships>`)
|
|
}
|
|
|
|
function addOverride(contentTypesXml: string, partName: string, contentType: string): string {
|
|
if (contentTypesXml.includes(`PartName="/${partName}"`)) return contentTypesXml
|
|
const insert = `<Override PartName="/${partName}" ContentType="${contentType}"/>`
|
|
return contentTypesXml.replace("</Types>", `${insert}</Types>`)
|
|
}
|
|
|
|
function patchDocumentSectPr(
|
|
documentXml: string,
|
|
refs: { header?: string; footer?: string; headerFirst?: string; footerFirst?: string },
|
|
differentFirst: boolean
|
|
): string {
|
|
const stripRefs = (input: string) =>
|
|
input
|
|
.replace(/<w:headerReference\b[^>]*\/?>/gi, "")
|
|
.replace(/<w:footerReference\b[^>]*\/?>/gi, "")
|
|
.replace(/<w:titlePg\b[^>]*\/?>/gi, "")
|
|
|
|
const buildRefs = () => {
|
|
const parts: string[] = []
|
|
if (refs.header) parts.push(`<w:headerReference w:type="default" r:id="${refs.header}"/>`)
|
|
if (refs.footer) parts.push(`<w:footerReference w:type="default" r:id="${refs.footer}"/>`)
|
|
if (differentFirst && refs.headerFirst) {
|
|
parts.push(`<w:headerReference w:type="first" r:id="${refs.headerFirst}"/>`)
|
|
}
|
|
if (differentFirst && refs.footerFirst) {
|
|
parts.push(`<w:footerReference w:type="first" r:id="${refs.footerFirst}"/>`)
|
|
}
|
|
if (differentFirst) parts.push("<w:titlePg/>")
|
|
return parts.join("")
|
|
}
|
|
|
|
const refsXml = buildRefs()
|
|
|
|
if (/<w:sectPr\b[^>]*>[\s\S]*?<\/w:sectPr>/i.test(documentXml)) {
|
|
return documentXml.replace(/<w:sectPr\b[^>]*>([\s\S]*?)<\/w:sectPr>/i, (_m, inner) => {
|
|
return `<w:sectPr>${stripRefs(inner)}${refsXml}</w:sectPr>`
|
|
})
|
|
}
|
|
|
|
return documentXml.replace(/<\/w:body>/i, `<w:sectPr>${refsXml}</w:sectPr></w:body>`)
|
|
}
|
|
|
|
/** Inject header/footer parts and references into a DOCX archive buffer. */
|
|
export async function patchDocxHeaderFooter(
|
|
buffer: Uint8Array,
|
|
pageSetup: DocPageSetup
|
|
): Promise<Uint8Array> {
|
|
const { unzipSync, zipSync } = await import("fflate")
|
|
const archive = { ...(unzipSync(buffer) as DocxArchive) }
|
|
|
|
const headerXml = await regionToPartXml(pageSetup.header, "hdr", pageSetup.pageNumbers)
|
|
const footerXml = await regionToPartXml(pageSetup.footer, "ftr", pageSetup.pageNumbers)
|
|
const headerFirstXml = pageSetup.headerFooterDifferentFirstPage
|
|
? await regionToPartXml(pageSetup.headerFirstPage, "hdr", pageSetup.pageNumbers)
|
|
: null
|
|
const footerFirstXml = pageSetup.headerFooterDifferentFirstPage
|
|
? await regionToPartXml(pageSetup.footerFirstPage, "ftr", pageSetup.pageNumbers)
|
|
: null
|
|
|
|
if (!headerXml && !footerXml && !headerFirstXml && !footerFirstXml) {
|
|
return buffer
|
|
}
|
|
|
|
let relsXml =
|
|
decodeXml(archive["word/_rels/document.xml.rels"]) ||
|
|
`<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="${REL_NS}"></Relationships>`
|
|
|
|
let contentTypesXml =
|
|
decodeXml(archive["[Content_Types].xml"]) ||
|
|
`<?xml version="1.0" encoding="UTF-8"?><Types xmlns="${CT_NS}"></Types>`
|
|
|
|
const refs: { header?: string; footer?: string; headerFirst?: string; footerFirst?: string } = {}
|
|
|
|
if (headerXml) {
|
|
archive["word/header1.xml"] = encodeXml(headerXml)
|
|
relsXml = addRelationship(
|
|
relsXml,
|
|
"rIdHeader1",
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
|
|
"header1.xml"
|
|
)
|
|
contentTypesXml = addOverride(
|
|
contentTypesXml,
|
|
"word/header1.xml",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"
|
|
)
|
|
refs.header = "rIdHeader1"
|
|
}
|
|
|
|
if (footerXml) {
|
|
archive["word/footer1.xml"] = encodeXml(footerXml)
|
|
relsXml = addRelationship(
|
|
relsXml,
|
|
"rIdFooter1",
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
|
|
"footer1.xml"
|
|
)
|
|
contentTypesXml = addOverride(
|
|
contentTypesXml,
|
|
"word/footer1.xml",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
|
|
)
|
|
refs.footer = "rIdFooter1"
|
|
}
|
|
|
|
if (headerFirstXml) {
|
|
archive["word/header2.xml"] = encodeXml(headerFirstXml)
|
|
relsXml = addRelationship(
|
|
relsXml,
|
|
"rIdHeader2",
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
|
|
"header2.xml"
|
|
)
|
|
contentTypesXml = addOverride(
|
|
contentTypesXml,
|
|
"word/header2.xml",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"
|
|
)
|
|
refs.headerFirst = "rIdHeader2"
|
|
}
|
|
|
|
if (footerFirstXml) {
|
|
archive["word/footer2.xml"] = encodeXml(footerFirstXml)
|
|
relsXml = addRelationship(
|
|
relsXml,
|
|
"rIdFooter2",
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
|
|
"footer2.xml"
|
|
)
|
|
contentTypesXml = addOverride(
|
|
contentTypesXml,
|
|
"word/footer2.xml",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
|
|
)
|
|
refs.footerFirst = "rIdFooter2"
|
|
}
|
|
|
|
archive["word/_rels/document.xml.rels"] = encodeXml(relsXml)
|
|
archive["[Content_Types].xml"] = encodeXml(contentTypesXml)
|
|
|
|
const documentXml = decodeXml(archive["word/document.xml"])
|
|
archive["word/document.xml"] = encodeXml(
|
|
patchDocumentSectPr(documentXml, refs, Boolean(pageSetup.headerFooterDifferentFirstPage))
|
|
)
|
|
|
|
return zipSync(archive)
|
|
}
|