399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
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(/<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,
|
||
}
|
||
}
|
||
|
||
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")
|
||
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<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
|
||
}
|
||
}
|