import type { DocPageBackground, DocPageBackgroundLayers } from "./doc-page-background.ts" import { extractDocxPageBackground, resolvePageBackgroundLayers } from "./doc-page-background.ts" import { DEFAULT_PAGE_FORMAT_ID, getPageFormat, MM_TO_PX, PAGE_FORMATS, type PageFormat, type PageFormatId, } from "./page-formats.ts" export type { DocPageBackground, DocPageBackgroundLayers, DocPageFillImage, DocPageWatermark } from "./doc-page-background.ts" export { extractDocxPageBackground, resolvePageBackgroundLayers } from "./doc-page-background.ts" const TWIPS_PER_MM = 1440 / 25.4 const FORMAT_MATCH_TOLERANCE_MM = 2 export type DocPageMarginsMm = { top: number right: number bottom: number left: number } export type DocPageBorderStyle = "solid" | "dashed" | "dotted" | "double" | "none" export type DocPageBorderSide = { style: DocPageBorderStyle widthPx: number color: string } export type DocPageBorders = { top: DocPageBorderSide right: DocPageBorderSide bottom: DocPageBorderSide left: DocPageBorderSide offsetFrom?: "page" | "text" } export type DocPageSetup = { widthMm: number heightMm: number marginsMm: DocPageMarginsMm formatId?: PageFormatId | null orientation?: "portrait" | "landscape" pageColor?: string | null pageBackground?: DocPageBackground | null borders?: DocPageBorders | null } export type DocPageBorderCss = { top?: string right?: string bottom?: string left?: string } export type DocPageLayout = { widthPx: number heightPx: number marginsPx: DocPageMarginsMm format: PageFormat pageColor: string pageBackgroundLayers?: DocPageBackgroundLayers sheetBorderCss?: DocPageBorderCss textAreaBorderCss?: DocPageBorderCss } export function twipsToMm(twips: number): number { return Math.round((twips / TWIPS_PER_MM) * 100) / 100 } export function mmToPx(mm: number): number { return Math.round(mm * MM_TO_PX) } export function defaultPageMarginsMm(): DocPageMarginsMm { const cm = 2 return { top: cm * 10, right: cm * 10, bottom: cm * 10, left: cm * 10 } } export function mmToCm(mm: number): number { return Math.round((mm / 10) * 10) / 10 } export function cmToMm(cm: number): number { return Math.round(cm * 10 * 100) / 100 } export function formatPaperSizeLabel(format: PageFormat): string { const w = format.widthMm / 10 const h = format.heightMm / 10 const fmt = (n: number) => n.toLocaleString("fr-FR", { maximumFractionDigits: 1 }) return `${format.label} (${fmt(w)} x ${fmt(h)} cm)` } export function normalizePageColor(color: string | null | undefined): string { if (!color || color.toLowerCase() === "#ffffff" || color.toLowerCase() === "white") { return "#ffffff" } return color } export function matchPageFormatId(widthMm: number, heightMm: number): PageFormatId | null { for (const format of PAGE_FORMATS) { const direct = Math.abs(format.widthMm - widthMm) <= FORMAT_MATCH_TOLERANCE_MM && Math.abs(format.heightMm - heightMm) <= FORMAT_MATCH_TOLERANCE_MM const rotated = Math.abs(format.widthMm - heightMm) <= FORMAT_MATCH_TOLERANCE_MM && Math.abs(format.heightMm - widthMm) <= FORMAT_MATCH_TOLERANCE_MM if (direct || rotated) return format.id } return null } export function resolveDocumentPageLayout( pageSetup: DocPageSetup | null | undefined, fallbackFormatId: PageFormatId = DEFAULT_PAGE_FORMAT_ID ): DocPageLayout { if (!pageSetup) { const format = getPageFormat(fallbackFormatId) const marginPx = mmToPx(defaultPageMarginsMm().top) return { widthPx: mmToPx(format.widthMm), heightPx: mmToPx(format.heightMm), marginsPx: { top: marginPx, right: marginPx, bottom: marginPx, left: marginPx }, format, pageColor: "#ffffff", pageBackgroundLayers: {}, } } const formatId = pageSetup.formatId ?? matchPageFormatId(pageSetup.widthMm, pageSetup.heightMm) const format = formatId ? getPageFormat(formatId) : null const borderLayers = pageSetup.borders ? bordersToLayoutCss(pageSetup.borders) : {} return { widthPx: mmToPx(pageSetup.widthMm), heightPx: mmToPx(pageSetup.heightMm), marginsPx: { top: mmToPx(pageSetup.marginsMm.top), right: mmToPx(pageSetup.marginsMm.right), bottom: mmToPx(pageSetup.marginsMm.bottom), left: mmToPx(pageSetup.marginsMm.left), }, format: format ?? { id: fallbackFormatId, label: `${pageSetup.widthMm} × ${pageSetup.heightMm} mm`, widthMm: pageSetup.widthMm, heightMm: pageSetup.heightMm, }, pageColor: normalizePageColor(pageSetup.pageColor), pageBackgroundLayers: resolvePageBackgroundLayers(pageSetup.pageBackground), ...borderLayers, } } export function mapWordBorderStyle(val: string): DocPageBorderStyle { switch (val.toLowerCase()) { case "nil": case "none": return "none" case "dotted": return "dotted" case "dashed": case "dotdash": case "dotdotdash": return "dashed" case "double": case "triple": return "double" default: return "solid" } } export function borderSzToPx(sz: number): number { if (sz <= 0) return 0 return Math.max(1, Math.round((sz / 8) * (96 / 72))) } export function parseWordBorderColor(raw: string | undefined): string { if (!raw || raw.toLowerCase() === "auto") return "#000000" if (raw.startsWith("#")) return raw if (/^[0-9a-f]{6}$/i.test(raw)) return `#${raw}` return "#000000" } export function parseWordBorderSide(tagXml: string): DocPageBorderSide { const val = tagXml.match(/\bw:val="([^"]+)"/i)?.[1] ?? "none" const sz = Number.parseInt(tagXml.match(/\bw:sz="(\d+)"/i)?.[1] ?? "0", 10) const color = parseWordBorderColor(tagXml.match(/\bw:color="([^"]+)"/i)?.[1]) return { style: mapWordBorderStyle(val), widthPx: borderSzToPx(Number.isFinite(sz) ? sz : 0), color, } } function borderSideToCss(side: DocPageBorderSide): string | undefined { if (side.style === "none" || side.widthPx <= 0) return undefined return `${side.widthPx}px ${side.style} ${side.color}` } export function bordersToLayoutCss(borders: DocPageBorders): { sheetBorderCss?: DocPageBorderCss textAreaBorderCss?: DocPageBorderCss } { const css: DocPageBorderCss = { top: borderSideToCss(borders.top), right: borderSideToCss(borders.right), bottom: borderSideToCss(borders.bottom), left: borderSideToCss(borders.left), } const hasAny = Boolean(css.top || css.right || css.bottom || css.left) if (!hasAny) return {} if (borders.offsetFrom === "page") { return { sheetBorderCss: css } } return { textAreaBorderCss: css } } function parsePgBorders(sectionXml: string): DocPageBorders | null { const block = sectionXml.match(/]*>[\s\S]*?<\/w:pgBorders>/i)?.[0] if (!block) return null const offsetFrom = block.match(/\bw:offsetFrom="page"/i) != null ? ("page" as const) : ("text" as const) const readSide = (name: "top" | "right" | "bottom" | "left"): DocPageBorderSide => { const match = block.match(new RegExp(`]*/?>`, "i")) return match ? parseWordBorderSide(match[0]) : { style: "none", widthPx: 0, color: "#000000" } } const borders: DocPageBorders = { top: readSide("top"), right: readSide("right"), bottom: readSide("bottom"), left: readSide("left"), offsetFrom, } const hasAny = [borders.top, borders.right, borders.bottom, borders.left].some( (side) => side.style !== "none" && side.widthPx > 0 ) return hasAny ? borders : null } export type PageSetupDraft = { formatId: PageFormatId orientation: "portrait" | "landscape" marginsCm: DocPageMarginsMm pageColor: string } export function draftFromPageSetup( setup: DocPageSetup | null | undefined, fallbackFormatId: PageFormatId ): PageSetupDraft { const formatId = setup?.formatId ?? (setup ? matchPageFormatId(setup.widthMm, setup.heightMm) : null) ?? fallbackFormatId const margins = setup?.marginsMm ?? defaultPageMarginsMm() return { formatId, orientation: setup?.orientation ?? "portrait", marginsCm: { top: mmToCm(margins.top), right: mmToCm(margins.right), bottom: mmToCm(margins.bottom), left: mmToCm(margins.left), }, pageColor: normalizePageColor(setup?.pageColor), } } function clampMarginCm(value: number): number { if (!Number.isFinite(value)) return 2 return Math.min(25, Math.max(0, Math.round(value * 10) / 10)) } export function buildPageSetupFromDraft( draft: PageSetupDraft, previous: DocPageSetup | null | undefined ): DocPageSetup { const format = getPageFormat(draft.formatId) let widthMm = format.widthMm let heightMm = format.heightMm if (draft.orientation === "landscape") { ;[widthMm, heightMm] = [heightMm, widthMm] } const pageColor = normalizePageColor(draft.pageColor) return { widthMm, heightMm, marginsMm: { top: cmToMm(clampMarginCm(draft.marginsCm.top)), right: cmToMm(clampMarginCm(draft.marginsCm.right)), bottom: cmToMm(clampMarginCm(draft.marginsCm.bottom)), left: cmToMm(clampMarginCm(draft.marginsCm.left)), }, formatId: draft.formatId, orientation: draft.orientation, pageColor: pageColor === "#ffffff" ? null : pageColor, pageBackground: previous?.pageBackground ?? null, borders: previous?.borders ?? null, } } export function buildPageSetupForFormat( formatId: PageFormatId, previous: DocPageSetup | null | undefined ): DocPageSetup { return buildPageSetupFromDraft({ ...draftFromPageSetup(previous, formatId), formatId }, previous) } function readTwipsAttr(fragment: string, attr: string): number | null { const match = fragment.match(new RegExp(`\\bw:${attr}="(\\d+)"`, "i")) if (!match) return null const value = Number.parseInt(match[1] ?? "", 10) return Number.isFinite(value) ? value : null } function parseSectPr(sectionXml: string): Omit | null { const pgSzMatch = sectionXml.match(/]*\/?>/i) const pgMarMatch = sectionXml.match(/]*\/?>/i) if (!pgSzMatch || !pgMarMatch) return null const widthTwips = readTwipsAttr(pgSzMatch[0], "w") const heightTwips = readTwipsAttr(pgSzMatch[0], "h") if (widthTwips == null || heightTwips == null) return null let widthMm = twipsToMm(widthTwips) let heightMm = twipsToMm(heightTwips) const orient = pgSzMatch[0].match(/\bw:orient="(landscape|portrait)"/i)?.[1]?.toLowerCase() if (orient === "landscape" && widthMm < heightMm) { ;[widthMm, heightMm] = [heightMm, widthMm] } const top = readTwipsAttr(pgMarMatch[0], "top") const right = readTwipsAttr(pgMarMatch[0], "right") const bottom = readTwipsAttr(pgMarMatch[0], "bottom") const left = readTwipsAttr(pgMarMatch[0], "left") if (top == null || right == null || bottom == null || left == null) return null const marginsMm = { top: twipsToMm(top), right: twipsToMm(right), bottom: twipsToMm(bottom), left: twipsToMm(left), } return { widthMm, heightMm, marginsMm, formatId: matchPageFormatId(widthMm, heightMm), orientation: orient === "landscape" ? "landscape" : "portrait", borders: parsePgBorders(sectionXml), } } /** Read Word section properties and page background from a DOCX buffer. */ export async function extractDocxPageSetup(buffer: ArrayBuffer): Promise { try { const { unzipSync } = await import("fflate") const archive = unzipSync(new Uint8Array(buffer)) as Record const documentXml = archive["word/document.xml"] if (!documentXml) return null const xml = new TextDecoder().decode(documentXml) const sections = [...xml.matchAll(/]*>[\s\S]*?<\/w:sectPr>/gi)] const sectionXml = sections.length > 0 ? sections[sections.length - 1]![0] : null let layout: Omit | null = null if (!sectionXml) { const inline = xml.match(/]*\/>/i) if (!inline) return null layout = parseSectPr(inline[0]) } else { layout = parseSectPr(sectionXml) } if (!layout) return null const backgroundInfo = extractDocxPageBackground(archive, xml) return { ...layout, pageColor: backgroundInfo.pageColor ?? null, pageBackground: backgroundInfo.background ?? null, } } catch { return null } }