ultisuite-client/lib/drive/doc-page-setup.ts
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

456 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 DocPageHeaderFooter = {
content: Record<string, unknown>
heightMm?: number
}
export type DocPageNumberSettings = {
enabled: boolean
placement: "header" | "footer"
align: "left" | "center" | "right"
startAt: number
showOnFirstPage: boolean
}
export type DocPageSetup = {
widthMm: number
heightMm: number
marginsMm: DocPageMarginsMm
/** Distance from page top to header content (cm/mm from top). */
headerMarginMm?: number
/** Distance from page bottom to footer content. */
footerMarginMm?: number
formatId?: PageFormatId | null
orientation?: "portrait" | "landscape"
pageColor?: string | null
pageBackground?: DocPageBackground | null
borders?: DocPageBorders | null
header?: DocPageHeaderFooter | null
footer?: DocPageHeaderFooter | null
headerFirstPage?: DocPageHeaderFooter | null
footerFirstPage?: DocPageHeaderFooter | null
headerFooterDifferentFirstPage?: boolean
headerFooterDifferentOddEven?: boolean
pageNumbers?: DocPageNumberSettings | 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
header?: DocPageHeaderFooter | null
footer?: DocPageHeaderFooter | null
headerFirstPage?: DocPageHeaderFooter | null
footerFirstPage?: DocPageHeaderFooter | null
headerMarginMm?: number
footerMarginMm?: number
headerFooterDifferentFirstPage?: boolean
headerFooterDifferentOddEven?: boolean
pageNumbers?: DocPageNumberSettings | null
}
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 pxToMm(px: number): number {
return Math.round((px / MM_TO_PX) * 100) / 100
}
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),
header: pageSetup.header ?? null,
footer: pageSetup.footer ?? null,
headerFirstPage: pageSetup.headerFirstPage ?? null,
footerFirstPage: pageSetup.footerFirstPage ?? null,
headerMarginMm: pageSetup.headerMarginMm,
footerMarginMm: pageSetup.footerMarginMm,
headerFooterDifferentFirstPage: pageSetup.headerFooterDifferentFirstPage ?? false,
headerFooterDifferentOddEven: pageSetup.headerFooterDifferentOddEven ?? false,
pageNumbers: pageSetup.pageNumbers ?? null,
...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(/<w:pgBorders\b[^>]*>[\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(`<w:${name}\\b[^>]*/?>`, "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,
header: previous?.header ?? null,
footer: previous?.footer ?? null,
headerMarginMm: previous?.headerMarginMm,
footerMarginMm: previous?.footerMarginMm,
headerFooterDifferentFirstPage: previous?.headerFooterDifferentFirstPage ?? false,
headerFooterDifferentOddEven: previous?.headerFooterDifferentOddEven ?? false,
pageNumbers: previous?.pageNumbers ?? 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<DocPageSetup, "pageColor" | "pageBackground"> | null {
const pgSzMatch = sectionXml.match(/<w:pgSz\b[^>]*\/?>/i)
const pgMarMatch = sectionXml.match(/<w:pgMar\b[^>]*\/?>/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")
const header = readTwipsAttr(pgMarMatch[0], "header")
const footer = readTwipsAttr(pgMarMatch[0], "footer")
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,
headerMarginMm: header != null ? twipsToMm(header) : marginsMm.top,
footerMarginMm: footer != null ? twipsToMm(footer) : marginsMm.bottom,
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<DocPageSetup | null> {
try {
const { unzipSync } = await import("fflate")
const archive = unzipSync(new Uint8Array(buffer)) as Record<string, Uint8Array>
const documentXml = archive["word/document.xml"]
if (!documentXml) return null
const xml = new TextDecoder().decode(documentXml)
const sections = [...xml.matchAll(/<w:sectPr\b[^>]*>[\s\S]*?<\/w:sectPr>/gi)]
const sectionXml = sections.length > 0 ? sections[sections.length - 1]![0] : null
let layout: Omit<DocPageSetup, "pageColor" | "pageBackground"> | null = null
if (!sectionXml) {
const inline = xml.match(/<w:sectPr\b[^>]*\/>/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
}
}