imports docx 1
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

This commit is contained in:
R3D347HR4Y 2026-06-10 00:27:44 +02:00
parent 79bb6193fc
commit 8e420509a8
25 changed files with 1806 additions and 130 deletions

View File

@ -9,7 +9,7 @@ NEXT_PUBLIC_WS_URL=ws://localhost/ws
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/ NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0 # URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail # URL publique navigateur (suite nginx) — pas :3004 si tu passes par http://localhost/mail
NEXT_PUBLIC_APP_URL=http://localhost NEXT_PUBLIC_APP_URL=http://localhost
# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://) # Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://)
# COOKIE_SECURE=false # COOKIE_SECURE=false

View File

@ -11,6 +11,7 @@ import type { PublicShareRootType } from "@/lib/drive/public-share-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title" import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity" import { getGuestEditorIdentity } from "@/lib/drive/guest-editor-identity"
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types" import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
import { fetchPublicShareBlob } from "@/lib/api/public-share" import { fetchPublicShareBlob } from "@/lib/api/public-share"
function fileNameFromPath(filePath: string, fallback?: string): string { function fileNameFromPath(filePath: string, fallback?: string): string {
@ -99,7 +100,11 @@ export function PublicRichTextEditor({
) )
const importApi = useCallback( const importApi = useCallback(
async (body: { source_path: string; content: Record<string, unknown> }) => { async (body: {
source_path: string
content: Record<string, unknown>
pageSetup?: DocPageSetup | null
}) => {
const res = await fetch( const res = await fetch(
`/api/v1/drive/public/shares/${encodeURIComponent(token)}/richtext/import`, `/api/v1/drive/public/shares/${encodeURIComponent(token)}/richtext/import`,
{ {

View File

@ -5,6 +5,7 @@ import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react" import { useEditor, EditorContent } from "@tiptap/react"
import { HocuspocusProvider } from "@hocuspocus/provider" import { HocuspocusProvider } from "@hocuspocus/provider"
import * as Y from "yjs" import * as Y from "yjs"
import { toast } from "sonner"
import { DocsChrome } from "@/components/drive/richtext/docs-chrome" import { DocsChrome } from "@/components/drive/richtext/docs-chrome"
import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view" import { DocsPageView, DocsStatusBar } from "@/components/drive/richtext/docs-page-view"
import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar" import { DocsToolbar } from "@/components/drive/richtext/docs-toolbar"
@ -17,7 +18,16 @@ import { useDocsViewSettings } from "@/lib/drive/docs-view-settings"
import { useCollabPresence } from "@/lib/drive/use-collab-presence" import { useCollabPresence } from "@/lib/drive/use-collab-presence"
import { apiClient } from "@/lib/api/client" import { apiClient } from "@/lib/api/client"
import { driveDownloadApiPath } from "@/lib/api/drive-download" import { driveDownloadApiPath } from "@/lib/api/drive-download"
import {
buildPageSetupForFormat,
buildPageSetupFromDraft,
resolveDocumentPageLayout,
type DocPageSetup,
} from "@/lib/drive/doc-page-setup"
import { readUserPageSetupDefaults } from "@/lib/drive/docs-page-defaults"
import { isEmptyTipTapDoc } from "@/lib/drive/richtext-content"
import { importFileToTipTap } from "@/lib/drive/richtext-import" import { importFileToTipTap } from "@/lib/drive/richtext-import"
import { isUltidocPath } from "@/lib/drive/richtext-formats"
const SAVE_DEBOUNCE_MS = 2000 const SAVE_DEBOUNCE_MS = 2000
/** Align with Hocuspocus store debounce + buffer */ /** Align with Hocuspocus store debounce + buffer */
@ -58,7 +68,11 @@ export function RichTextDocumentEditor({
userColor: string userColor: string
onSaveStatus?: (status: RichTextSaveStatus) => void onSaveStatus?: (status: RichTextSaveStatus) => void
fetchSourceBytes?: (path: string) => Promise<ArrayBuffer> fetchSourceBytes?: (path: string) => Promise<ArrayBuffer>
importApi?: (body: { source_path: string; content: Record<string, unknown> }) => Promise<void> importApi?: (body: {
source_path: string
content: Record<string, unknown>
pageSetup?: DocPageSetup | null
}) => Promise<void>
chrome?: RichTextDocsChromeProps chrome?: RichTextDocsChromeProps
}) { }) {
const editable = mode === "edit" const editable = mode === "edit"
@ -73,15 +87,26 @@ export function RichTextDocumentEditor({
const [collabSynced, setCollabSynced] = useState(false) const [collabSynced, setCollabSynced] = useState(false)
const [collabError, setCollabError] = useState<string | null>(null) const [collabError, setCollabError] = useState<string | null>(null)
const [importDone, setImportDone] = useState(!session.importRequired) const [importDone, setImportDone] = useState(!session.importRequired)
const [contentImportPending, setContentImportPending] = useState(session.importRequired)
const [importedContent, setImportedContent] = useState<Record<string, unknown> | null>(null)
const [documentPageSetup, setDocumentPageSetup] = useState<DocPageSetup | null>(
session.pageSetup ?? null
)
const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle") const [saveStatus, setSaveStatus] = useState<RichTextSaveStatus>("idle")
const [pageCount, setPageCount] = useState(1) const [pageCount, setPageCount] = useState(1)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveStatusRef = useRef<RichTextSaveStatus>("idle") const saveStatusRef = useRef<RichTextSaveStatus>("idle")
const reloadAfterReimportRef = useRef(false)
const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } = const { settings, setPageFormatId, setZoom, toggleSpellcheck, toggleChromeCollapsed } =
useDocsViewSettings() useDocsViewSettings()
const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor }) const presenceUsers = useCollabPresence(provider, { name: userName, color: userColor })
const pageLayout = useMemo(
() => resolveDocumentPageLayout(documentPageSetup, settings.pageFormatId),
[documentPageSetup, settings.pageFormatId]
)
const activePageFormatId = pageLayout.format.id
const reportSaveStatus = useCallback( const reportSaveStatus = useCallback(
(status: RichTextSaveStatus) => { (status: RichTextSaveStatus) => {
@ -92,10 +117,93 @@ export function RichTextDocumentEditor({
[onSaveStatus] [onSaveStatus]
) )
const persistPageSetup = useCallback(
async (setup: DocPageSetup) => {
if (!editable) return
setDocumentPageSetup(setup)
if (setup.formatId) setPageFormatId(setup.formatId)
reportSaveStatus("saving")
try {
const body = JSON.stringify({ pageSetup: setup })
if (session.saveUrl) {
const res = await fetch(session.saveUrl, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
})
if (!res.ok) throw new Error("save failed")
} else {
await apiClient.put("/richtext/save", {
path: session.canonicalPath,
pageSetup: setup,
})
}
reportSaveStatus("saved")
} catch {
reportSaveStatus("error")
}
},
[editable, reportSaveStatus, session.canonicalPath, session.saveUrl, setPageFormatId]
)
const handlePageFormatChange = useCallback(
(formatId: typeof settings.pageFormatId) => {
void persistPageSetup(buildPageSetupForFormat(formatId, documentPageSetup))
},
[documentPageSetup, persistPageSetup, settings.pageFormatId]
)
useEffect(() => {
if (session.pageSetup) setDocumentPageSetup(session.pageSetup)
}, [session.pageSetup])
useEffect(() => {
if (session.pageSetup) return
setDocumentPageSetup((current) => {
if (current) return current
const setup = buildPageSetupFromDraft(
readUserPageSetupDefaults(settings.pageFormatId),
null
)
if (setup.formatId) setPageFormatId(setup.formatId)
return setup
})
}, [session.pageSetup, settings.pageFormatId, setPageFormatId])
const handlePageCountChange = useCallback((count: number) => { const handlePageCountChange = useCallback((count: number) => {
setPageCount(count) setPageCount(count)
}, []) }, [])
const handlePurgeSidecarAndReimport = useCallback(async () => {
if (!editable) return
const source = session.sourcePath || session.canonicalPath
if (!source || isUltidocPath(source)) {
toast.error("Aucun fichier source à réimporter")
return
}
if (
!window.confirm(
"Supprimer le sidecar (.ultidoc.json) et réimporter le document source ? Cette action est temporaire (dev)."
)
) {
return
}
reportSaveStatus("saving")
try {
await apiClient.delete(`/drive/files${session.canonicalPath}`)
setDocumentPageSetup(null)
setImportedContent(null)
reloadAfterReimportRef.current = collaboration
setImportDone(false)
setContentImportPending(true)
toast.success("Sidecar purgé — réimport en cours…")
} catch {
reportSaveStatus("error")
toast.error("Impossible de purger le sidecar")
}
}, [collaboration, editable, reportSaveStatus, session.canonicalPath, session.sourcePath])
const markCollabDirty = useCallback(() => { const markCollabDirty = useCallback(() => {
if (saveStatusRef.current !== "saving") { if (saveStatusRef.current !== "saving") {
reportSaveStatus("saving") reportSaveStatus("saving")
@ -114,7 +222,38 @@ export function RichTextDocumentEditor({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!session.importRequired || importDone) return setContentImportPending(session.importRequired)
setImportDone(!session.importRequired)
}, [session.importRequired, session.canonicalPath])
useEffect(() => {
if (session.importRequired || !session.sourcePath) return
let cancelled = false
void (async () => {
try {
let parsed: { content?: Record<string, unknown> }
if (session.documentUrl) {
const res = await fetch(session.documentUrl)
if (!res.ok) return
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown> }
} else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> }
}
if (cancelled || !isEmptyTipTapDoc(parsed.content)) return
setContentImportPending(true)
setImportDone(false)
} catch {
/* keep current import flags */
}
})()
return () => {
cancelled = true
}
}, [session.canonicalPath, session.documentUrl, session.importRequired, session.sourcePath])
useEffect(() => {
if (!contentImportPending || importDone) return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
reportSaveStatus("saving") reportSaveStatus("saving")
@ -123,17 +262,29 @@ export function RichTextDocumentEditor({
const buf = fetchSourceBytes const buf = fetchSourceBytes
? await fetchSourceBytes(source) ? await fetchSourceBytes(source)
: await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer() : await (await apiClient.getBlob(driveDownloadApiPath(source))).arrayBuffer()
const content = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf) const imported = await importFileToTipTap(source.split("/").pop() ?? "file.docx", buf)
if (cancelled) return if (cancelled) return
const payload = { source_path: source, content } const payload = {
source_path: source,
content: imported.content,
pageSetup: imported.pageSetup ?? undefined,
}
if (importApi) { if (importApi) {
await importApi(payload) await importApi(payload)
} else { } else {
await apiClient.post("/richtext/import", payload) await apiClient.post("/richtext/import", payload)
} }
if (!cancelled) { if (!cancelled) {
setImportedContent(imported.content as Record<string, unknown>)
if (imported.pageSetup) setDocumentPageSetup(imported.pageSetup)
setContentImportPending(false)
setImportDone(true) setImportDone(true)
reportSaveStatus("saved") reportSaveStatus("saved")
if (reloadAfterReimportRef.current) {
reloadAfterReimportRef.current = false
window.location.reload()
return
}
} }
} catch { } catch {
if (!cancelled) reportSaveStatus("error") if (!cancelled) reportSaveStatus("error")
@ -142,7 +293,7 @@ export function RichTextDocumentEditor({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [session, importDone, fetchSourceBytes, importApi, reportSaveStatus]) }, [contentImportPending, importDone, session, fetchSourceBytes, importApi, reportSaveStatus])
useEffect(() => { useEffect(() => {
if (!collaboration || !ydoc || !importDone) return if (!collaboration || !ydoc || !importDone) return
@ -263,8 +414,10 @@ export function RichTextDocumentEditor({
const fileMenu = useDocsFileMenu({ const fileMenu = useDocsFileMenu({
file: chrome?.file, file: chrome?.file,
editor, editor,
pageFormatId: settings.pageFormatId, pageSetup: documentPageSetup,
onPageFormatChange: setPageFormatId, fallbackFormatId: settings.pageFormatId,
onPageSetupApply: (setup) => void persistPageSetup(setup),
onPurgeSidecarAndReimport: () => void handlePurgeSidecarAndReimport(),
onShareClick: chrome?.onShareClick, onShareClick: chrome?.onShareClick,
onRenameRequest: chrome?.onRenameRequest, onRenameRequest: chrome?.onRenameRequest,
onFileMoved: chrome?.onFileMoved, onFileMoved: chrome?.onFileMoved,
@ -289,20 +442,43 @@ export function RichTextDocumentEditor({
: undefined : undefined
useEffect(() => { useEffect(() => {
if (!editor || collaboration || !importDone || session.importRequired) return if (!editor || collaboration || !importDone) return
if (importedContent) {
editor.commands.setContent(importedContent)
setImportedContent(null)
return
}
if (contentImportPending) return
let cancelled = false let cancelled = false
void (async () => { void (async () => {
try { try {
let parsed: { content?: Record<string, unknown> } let parsed: { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
if (session.documentUrl) { if (session.documentUrl) {
const res = await fetch(session.documentUrl) const res = await fetch(session.documentUrl)
if (!res.ok) throw new Error("load failed") if (!res.ok) throw new Error("load failed")
parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown> } parsed = JSON.parse(await res.text()) as { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
} else { } else {
const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath)) const blob = await apiClient.getBlob(driveDownloadApiPath(session.canonicalPath))
parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown> } parsed = JSON.parse(await blob.text()) as { content?: Record<string, unknown>; pageSetup?: DocPageSetup }
}
if (!cancelled && parsed.pageSetup) {
setDocumentPageSetup(parsed.pageSetup)
} else if (!cancelled) {
setDocumentPageSetup(
(current) =>
current ??
buildPageSetupFromDraft(readUserPageSetupDefaults(settings.pageFormatId), null)
)
}
if (!cancelled && parsed.content && !isEmptyTipTapDoc(parsed.content)) {
editor.commands.setContent(parsed.content)
} else if (!cancelled && session.sourcePath) {
setContentImportPending(true)
setImportDone(false)
} }
if (!cancelled && parsed.content) editor.commands.setContent(parsed.content)
} catch { } catch {
/* blank */ /* blank */
} }
@ -310,7 +486,17 @@ export function RichTextDocumentEditor({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [editor, collaboration, importDone, session.canonicalPath, session.documentUrl, session.importRequired]) }, [
editor,
collaboration,
importDone,
importedContent,
contentImportPending,
session.canonicalPath,
session.documentUrl,
session.sourcePath,
settings.pageFormatId,
])
if (collabError) { if (collabError) {
return ( return (
@ -322,7 +508,9 @@ export function RichTextDocumentEditor({
if (!editorEnabled || !editor) { if (!editorEnabled || !editor) {
const statusText = const statusText =
session.importRequired && !importDone contentImportPending && !importDone
? "Import du document…"
: session.importRequired && !importDone
? "Import du document…" ? "Import du document…"
: collaboration && !collabSynced : collaboration && !collabSynced
? "Connexion à la collaboration…" ? "Connexion à la collaboration…"
@ -334,8 +522,8 @@ export function RichTextDocumentEditor({
{...chromeProps} {...chromeProps}
saveStatus={saveStatus} saveStatus={saveStatus}
presenceUsers={presenceUsers} presenceUsers={presenceUsers}
pageFormatId={settings.pageFormatId} pageFormatId={activePageFormatId}
onPageFormatChange={setPageFormatId} onPageFormatChange={handlePageFormatChange}
zoom={settings.zoom} zoom={settings.zoom}
onZoomChange={setZoom} onZoomChange={setZoom}
/> />
@ -354,8 +542,8 @@ export function RichTextDocumentEditor({
{...chromeProps} {...chromeProps}
saveStatus={saveStatus} saveStatus={saveStatus}
presenceUsers={presenceUsers} presenceUsers={presenceUsers}
pageFormatId={settings.pageFormatId} pageFormatId={activePageFormatId}
onPageFormatChange={setPageFormatId} onPageFormatChange={handlePageFormatChange}
zoom={settings.zoom} zoom={settings.zoom}
onZoomChange={setZoom} onZoomChange={setZoom}
/> />
@ -375,7 +563,7 @@ export function RichTextDocumentEditor({
{chrome ? ( {chrome ? (
<DocsPageView <DocsPageView
editor={editor} editor={editor}
pageFormatId={settings.pageFormatId} pageLayout={pageLayout}
zoom={settings.zoom} zoom={settings.zoom}
editable={editable} editable={editable}
onPageCountChange={handlePageCountChange} onPageCountChange={handlePageCountChange}
@ -386,7 +574,7 @@ export function RichTextDocumentEditor({
</div> </div>
)} )}
{chrome ? ( {chrome ? (
<DocsStatusBar pageFormatId={settings.pageFormatId} pageCount={pageCount} /> <DocsStatusBar pageLayout={pageLayout} pageCount={pageCount} />
) : null} ) : null}
</div> </div>
) )

View File

@ -44,17 +44,19 @@ export function RichTextEditor({ fileId }: { fileId: string }) {
const title = displayFileBaseName(fileName) const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title) useDriveDocumentTitle(title)
const sessionReturnTo = readDriveEditorReturnTo() const [backHref, setBackHref] = useState("/drive")
const backHref = useMemo(
() => useEffect(() => {
if (!displayPath) return
setBackHref(
resolveDriveEditReturnTo( resolveDriveEditReturnTo(
null, null,
displayPath, displayPath,
(folderPath) => driveFolderHref("files", folderPath), (folderPath) => driveFolderHref("files", folderPath),
sessionReturnTo readDriveEditorReturnTo()
), )
[displayPath, sessionReturnTo] )
) }, [displayPath])
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath)) const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
const { rename } = useDriveMutations() const { rename } = useDriveMutations()

View File

@ -58,6 +58,8 @@ export type DocsFileMenuActions = {
onSecurityLimits: () => void onSecurityLimits: () => void
onPageSetup: () => void onPageSetup: () => void
onPrint: () => void onPrint: () => void
/** Dev-only: purge .ultidoc.json sidecar and force DOCX reimport. */
onPurgeSidecarAndReimport?: () => void
} }
function MenuIcon({ children }: { children: ReactNode }) { function MenuIcon({ children }: { children: ReactNode }) {
@ -282,6 +284,22 @@ export function DocsFileMenu({
Configuration de la page Configuration de la page
</MenubarItem> </MenubarItem>
{actions.onPurgeSidecarAndReimport ? (
<>
<MenubarSeparator />
<MenubarItem
className={cn("docs-menu-item", "text-amber-700 focus:text-amber-800 dark:text-amber-400")}
disabled={disabled}
onClick={actions.onPurgeSidecarAndReimport}
>
<MenuIcon>
<Trash2 className="size-4" />
</MenuIcon>
Purger sidecar et réimporter doc
</MenubarItem>
</>
) : null}
<MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPrint}> <MenubarItem className="docs-menu-item" disabled={disabled} onClick={actions.onPrint}>
<MenuIcon> <MenuIcon>
<Printer className="size-4" /> <Printer className="size-4" />

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@ -9,79 +10,292 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { import {
DRIVE_BTN_GHOST, DRIVE_BTN_GHOST,
DRIVE_BTN_PRIMARY, DRIVE_BTN_PRIMARY,
DRIVE_DIALOG_BODY,
DRIVE_DIALOG_CONTENT, DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_FOOTER, DRIVE_DIALOG_FOOTER,
DRIVE_DIALOG_HEADER,
DRIVE_DIALOG_OVERLAY, DRIVE_DIALOG_OVERLAY,
DRIVE_TEXT_SECONDARY, DRIVE_TEXT_SECONDARY,
DRIVE_TEXT_TITLE, DRIVE_TEXT_TITLE,
} from "@/lib/drive/drive-dialog-styles" } from "@/lib/drive/drive-dialog-styles"
import {
buildPageSetupFromDraft,
draftFromPageSetup,
formatPaperSizeLabel,
type DocPageSetup,
type PageSetupDraft,
} from "@/lib/drive/doc-page-setup"
import {
pageSetupDraftsEqual,
readUserPageSetupDefaults,
saveUserPageSetupDefaults,
} from "@/lib/drive/docs-page-defaults"
import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats" import { PAGE_FORMATS, type PageFormatId } from "@/lib/drive/page-formats"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type MarginSide = keyof PageSetupDraft["marginsCm"]
const MARGIN_FIELDS: { key: MarginSide; label: string }[] = [
{ key: "top", label: "Haut" },
{ key: "bottom", label: "Bas" },
{ key: "left", label: "Gauche" },
{ key: "right", label: "Droite" },
]
const FIELD_LABEL = "text-xs font-medium text-muted-foreground"
const FIELD_CONTROL = "h-9"
function parseMarginInput(raw: string): number {
const normalized = raw.replace(",", ".").trim()
if (!normalized) return 0
const value = Number.parseFloat(normalized)
return Number.isFinite(value) ? value : 0
}
export function DocsPageSetupDialog({ export function DocsPageSetupDialog({
open, open,
onOpenChange, onOpenChange,
pageFormatId, pageSetup,
onPageFormatChange, fallbackFormatId,
onApply,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
pageFormatId: PageFormatId pageSetup: DocPageSetup | null
onPageFormatChange: (id: PageFormatId) => void fallbackFormatId: PageFormatId
onApply: (setup: DocPageSetup) => void
}) { }) {
const [draft, setDraft] = useState<PageSetupDraft>(() =>
draftFromPageSetup(pageSetup, fallbackFormatId)
)
const [savedDefaults, setSavedDefaults] = useState<PageSetupDraft>(() =>
readUserPageSetupDefaults(fallbackFormatId)
)
useEffect(() => {
if (open) {
setDraft(draftFromPageSetup(pageSetup, fallbackFormatId))
setSavedDefaults(readUserPageSetupDefaults(fallbackFormatId))
}
}, [open, pageSetup, fallbackFormatId])
const matchesSavedDefaults = useMemo(
() => pageSetupDraftsEqual(draft, savedDefaults),
[draft, savedDefaults]
)
const handleApply = () => {
onApply(buildPageSetupFromDraft(draft, pageSetup))
onOpenChange(false)
}
const handleSaveDefaults = () => {
saveUserPageSetupDefaults(draft)
setSavedDefaults({ ...draft })
}
const updateMargin = (key: MarginSide, raw: string) => {
setDraft((prev) => ({
...prev,
marginsCm: { ...prev.marginsCm, [key]: parseMarginInput(raw) },
}))
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
overlayClassName={DRIVE_DIALOG_OVERLAY} overlayClassName={DRIVE_DIALOG_OVERLAY}
className={cn(DRIVE_DIALOG_CONTENT, "sm:max-w-[420px]")} className={cn(
DRIVE_DIALOG_CONTENT,
"h-auto max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] gap-0 overflow-y-auto p-0 sm:max-w-[480px]"
)}
> >
<DialogHeader className={DRIVE_DIALOG_HEADER}> <DialogHeader className="space-y-0 px-5 py-3 text-left">
<DialogTitle className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}> <DialogTitle className={cn("text-lg font-normal", DRIVE_TEXT_TITLE)}>
Configuration de la page Configuration de la page
</DialogTitle> </DialogTitle>
<DialogDescription className={cn("text-sm", DRIVE_TEXT_SECONDARY)}> <DialogDescription className="sr-only">
Format de page utilisé pour l&apos;affichage et l&apos;impression. Format, orientation, couleur et marges du document.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className={cn(DRIVE_DIALOG_BODY, "space-y-1")}>
{PAGE_FORMATS.map((format) => ( <Tabs defaultValue="pages" className="gap-0">
<button <div className="px-5">
key={format.id} <TabsList className="h-9 w-full gap-0 rounded-lg bg-[#f1f3f4] p-1 dark:bg-muted">
type="button" <TabsTrigger
className={cn( value="pages"
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-accent", className="flex-1 rounded-md px-4 text-sm font-medium text-muted-foreground shadow-none data-[state=active]:bg-white data-[state=active]:text-[#1a73e8] data-[state=active]:shadow-sm dark:data-[state=active]:bg-background dark:data-[state=active]:text-[#8ab4f8]"
pageFormatId === format.id && "bg-accent"
)}
onClick={() => onPageFormatChange(format.id)}
> >
<span>{format.label}</span> Pages
<span className="text-xs text-muted-foreground"> </TabsTrigger>
{format.widthMm} × {format.heightMm} mm <TabsTrigger
</span> value="pageless"
</button> disabled
))} className="flex-1 rounded-md px-4 text-sm font-medium text-muted-foreground/50 shadow-none"
</div> >
<DialogFooter className={DRIVE_DIALOG_FOOTER}> Sans pages
<Button </TabsTrigger>
type="button" </TabsList>
className={DRIVE_BTN_PRIMARY} </div>
onClick={() => onOpenChange(false)}
> <TabsContent value="pages" className="mt-0 px-5 py-3">
OK <div className="grid grid-cols-2 gap-x-4 gap-y-3">
</Button> <div className="col-span-2 space-y-1">
<Label className={FIELD_LABEL}>Appliquer à</Label>
<Select value="document" disabled>
<SelectTrigger className={FIELD_CONTROL}>
<SelectValue>Au document entier</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="document">Au document entier</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className={FIELD_LABEL}>Orientation</Label>
<RadioGroup
value={draft.orientation}
onValueChange={(value: "portrait" | "landscape") =>
setDraft((prev) => ({ ...prev, orientation: value }))
}
className="flex h-9 items-center gap-5"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="portrait" id="docs-page-orientation-portrait" />
<Label htmlFor="docs-page-orientation-portrait" className="text-sm font-normal">
Portrait
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="landscape" id="docs-page-orientation-landscape" />
<Label htmlFor="docs-page-orientation-landscape" className="text-sm font-normal">
Paysage
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-1">
<Label className={FIELD_LABEL}>Format de papier</Label>
<Select
value={draft.formatId}
onValueChange={(value: PageFormatId) =>
setDraft((prev) => ({ ...prev, formatId: value }))
}
>
<SelectTrigger className={FIELD_CONTROL}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_FORMATS.map((format) => (
<SelectItem key={format.id} value={format.id}>
{formatPaperSizeLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className={FIELD_LABEL}>Couleur de la page</Label>
<div className="flex h-9 items-center">
<label className="relative inline-flex h-8 w-8 cursor-pointer items-center justify-center">
<span
className="block h-7 w-7 rounded-full border border-[#dadce0] dark:border-border"
style={{ backgroundColor: draft.pageColor }}
aria-hidden
/>
<input
type="color"
value={draft.pageColor}
onChange={(event) =>
setDraft((prev) => ({ ...prev, pageColor: event.target.value }))
}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-label="Couleur de la page"
/>
</label>
</div>
</div>
<div className="col-span-2 space-y-1">
<Label className={FIELD_LABEL}>Marges (centimètres)</Label>
<div className="grid grid-cols-4 gap-2">
{MARGIN_FIELDS.map(({ key, label }) => (
<div key={key} className="space-y-1">
<Label
htmlFor={`docs-page-margin-${key}`}
className="text-[11px] font-normal text-muted-foreground"
>
{label}
</Label>
<Input
id={`docs-page-margin-${key}`}
type="text"
inputMode="decimal"
className={cn(FIELD_CONTROL, "px-2 text-center text-sm")}
value={String(draft.marginsCm[key])}
onChange={(event) => updateMargin(key, event.target.value)}
/>
</div>
))}
</div>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter
className={cn(
DRIVE_DIALOG_FOOTER,
"flex flex-wrap items-center justify-end gap-2 px-5 py-3 sm:justify-end"
)}
>
{matchesSavedDefaults ? (
<span className={cn("order-1 text-sm", DRIVE_TEXT_SECONDARY)}>
Valeurs par défaut enregistrées
</span>
) : (
<Button
type="button"
variant="link"
className="order-1 h-8 px-0 text-sm font-normal text-[#1a73e8] hover:text-[#174ea6]"
onClick={handleSaveDefaults}
>
Enregistrer comme valeurs par défaut
</Button>
)}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
className={DRIVE_BTN_GHOST} className={cn(
DRIVE_BTN_GHOST,
"order-2 h-8 rounded-full px-4 text-[#1a73e8] hover:bg-transparent hover:text-[#174ea6]"
)}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Annuler Annuler
</Button> </Button>
<Button
type="button"
className={cn(DRIVE_BTN_PRIMARY, "order-3 h-8 rounded-full px-5")}
onClick={handleApply}
>
OK
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -2,13 +2,7 @@
import { memo, useEffect, useRef, useState } from "react" import { memo, useEffect, useRef, useState } from "react"
import { EditorContent, type Editor } from "@tiptap/react" import { EditorContent, type Editor } from "@tiptap/react"
import { import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
getPageFormat,
PAGE_MARGIN_PX,
pageFormatHeightPx,
pageFormatWidthPx,
type PageFormatId,
} from "@/lib/drive/page-formats"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer" import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
@ -29,20 +23,20 @@ function measureProseContentHeight(prose: HTMLElement): number {
function DocsPageViewInner({ function DocsPageViewInner({
editor, editor,
pageFormatId, pageLayout,
zoom, zoom,
editable, editable,
onPageCountChange, onPageCountChange,
}: { }: {
editor: Editor editor: Editor
pageFormatId: PageFormatId pageLayout: DocPageLayout
zoom: number zoom: number
editable: boolean editable: boolean
onPageCountChange?: (count: number) => void onPageCountChange?: (count: number) => void
}) { }) {
const format = getPageFormat(pageFormatId) const pageWidth = pageLayout.widthPx
const pageWidth = pageFormatWidthPx(format) const pageHeight = pageLayout.heightPx
const pageHeight = pageFormatHeightPx(format) const margins = pageLayout.marginsPx
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
const [pageCount, setPageCount] = useState(1) const [pageCount, setPageCount] = useState(1)
@ -78,13 +72,9 @@ function DocsPageViewInner({
if (!prose) return if (!prose) return
const contentHeight = measureProseContentHeight(prose) const contentHeight = measureProseContentHeight(prose)
const paddedHeight = PAGE_MARGIN_PX * 2 + contentHeight const paddedHeight = margins.top + margins.bottom + contentHeight
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight)) const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
setPageCount((prev) => { setPageCount((prev) => (prev === count ? prev : count))
if (prev === count) return prev
onPageCountChangeRef.current?.(count)
return count
})
} }
const scheduleMeasure = () => { const scheduleMeasure = () => {
@ -105,12 +95,71 @@ function DocsPageViewInner({
ro?.disconnect() ro?.disconnect()
editor.off("transaction", onTransaction) editor.off("transaction", onTransaction)
} }
}, [pageHeight, editor]) }, [margins.bottom, margins.top, pageHeight, editor])
useEffect(() => {
onPageCountChangeRef.current?.(pageCount)
}, [pageCount])
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
const proseMinHeight = stackHeight - PAGE_MARGIN_PX * 2 const innerMinHeight = Math.max(pageHeight - margins.top - margins.bottom, stackHeight - margins.top - margins.bottom)
const scaledHeight = stackHeight * scale const scaledHeight = stackHeight * scale
const verticalPadding = narrowViewport ? 32 : 64 const verticalPadding = narrowViewport ? 32 : 64
const textAreaBorderCss = pageLayout.textAreaBorderCss
const sheetBorderCss = pageLayout.sheetBorderCss
const pageBackground = pageLayout.pageColor
const backgroundLayers = pageLayout.pageBackgroundLayers
const renderPageBackground = (index: number) => (
<>
{backgroundLayers?.gradientCss ? (
<div
key={`bg-gradient-${index}`}
className="pointer-events-none absolute inset-0 z-[1]"
style={{ background: backgroundLayers.gradientCss }}
aria-hidden
/>
) : null}
{backgroundLayers?.fillImageStyle ? (
<div
key={`bg-image-${index}`}
className="pointer-events-none absolute inset-0 z-[1]"
style={backgroundLayers.fillImageStyle}
aria-hidden
/>
) : null}
{backgroundLayers?.watermarkStyle ? (
<div
key={`bg-watermark-${index}`}
className="pointer-events-none absolute inset-0 z-[2] flex items-center justify-center overflow-hidden"
aria-hidden
>
{backgroundLayers.watermarkStyle.imageSrc ? (
<img
src={backgroundLayers.watermarkStyle.imageSrc}
alt=""
className="max-h-[70%] max-w-[70%] select-none object-contain"
style={{
opacity: backgroundLayers.watermarkStyle.opacity,
transform: `rotate(${backgroundLayers.watermarkStyle.rotationDeg}deg)`,
}}
/>
) : (
<span
className="select-none whitespace-nowrap text-[72px] font-light leading-none"
style={{
color: backgroundLayers.watermarkStyle.color,
opacity: backgroundLayers.watermarkStyle.opacity,
transform: `rotate(${backgroundLayers.watermarkStyle.rotationDeg}deg)`,
}}
>
{backgroundLayers.watermarkStyle.text}
</span>
)}
</div>
) : null}
</>
)
return ( return (
<div <div
@ -142,28 +191,59 @@ function DocsPageViewInner({
{Array.from({ length: pageCount }, (_, index) => ( {Array.from({ length: pageCount }, (_, index) => (
<div <div
key={index} key={index}
className="ultidrive-docs-page absolute left-0 bg-white dark:bg-white" className={cn(
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white",
sheetBorderCss && "ultidrive-docs-page--imported-border"
)}
style={{ style={{
top: index * (pageHeight + PAGE_GAP_PX), top: index * (pageHeight + PAGE_GAP_PX),
width: pageWidth, width: pageWidth,
height: pageHeight, height: pageHeight,
backgroundColor: pageBackground,
boxShadow: boxShadow:
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)", "0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
...(sheetBorderCss
? {
borderTop: sheetBorderCss.top ?? "none",
borderRight: sheetBorderCss.right ?? "none",
borderBottom: sheetBorderCss.bottom ?? "none",
borderLeft: sheetBorderCss.left ?? "none",
}
: {}),
}} }}
aria-hidden aria-hidden
/> >
{renderPageBackground(index)}
</div>
))} ))}
{textAreaBorderCss
? Array.from({ length: pageCount }, (_, index) => (
<div
key={`text-border-${index}`}
className="pointer-events-none absolute z-[5] box-border"
style={{
top: index * (pageHeight + PAGE_GAP_PX) + margins.top,
left: margins.left,
width: pageWidth - margins.left - margins.right,
height: pageHeight - margins.top - margins.bottom,
borderTop: textAreaBorderCss.top ?? "none",
borderRight: textAreaBorderCss.right ?? "none",
borderBottom: textAreaBorderCss.bottom ?? "none",
borderLeft: textAreaBorderCss.left ?? "none",
}}
aria-hidden
/>
))
: null}
<div <div
ref={contentRef} ref={contentRef}
className="ultidrive-docs-editor-surface relative z-10" className="ultidrive-docs-editor-surface relative z-10"
style={{ style={{
padding: PAGE_MARGIN_PX, padding: `${margins.top}px ${margins.right}px ${margins.bottom}px ${margins.left}px`,
minHeight: stackHeight, minHeight: stackHeight,
["--docs-prose-min-height" as string]: `${Math.max( ["--docs-prose-min-height" as string]: `${innerMinHeight}px`,
pageHeight - PAGE_MARGIN_PX * 2,
proseMinHeight
)}px`,
}} }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (!editable) return if (!editable) return
@ -188,16 +268,14 @@ function DocsPageViewInner({
export const DocsPageView = memo(DocsPageViewInner) export const DocsPageView = memo(DocsPageViewInner)
export function DocsStatusBar({ export function DocsStatusBar({
pageFormatId, pageLayout,
pageCount, pageCount,
className, className,
}: { }: {
pageFormatId: PageFormatId pageLayout: DocPageLayout
pageCount: number pageCount: number
className?: string className?: string
}) { }) {
const format = getPageFormat(pageFormatId)
return ( return (
<div <div
className={cn( className={cn(
@ -207,7 +285,7 @@ export function DocsStatusBar({
> >
<span>Page 1 sur {pageCount}</span> <span>Page 1 sur {pageCount}</span>
<span> <span>
{format.label} ({format.widthMm} × {format.heightMm} mm) {pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
</span> </span>
</div> </div>
) )

View File

@ -7,7 +7,7 @@ function trimSlash(url: string) {
/** Public app origin for OAuth redirects and post-login navigation (never 0.0.0.0). */ /** Public app origin for OAuth redirects and post-login navigation (never 0.0.0.0). */
export function getAppOrigin(): string { export function getAppOrigin(): string {
const raw = ( const raw = (
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3004"
).replace(/\/$/, "") ).replace(/\/$/, "")
try { try {
@ -17,7 +17,7 @@ export function getAppOrigin(): string {
} }
return url.origin return url.origin
} catch { } catch {
return "http://localhost:3000" return "http://localhost:3004"
} }
} }

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
extractDocxPageBackground,
parseDocumentBackgroundXml,
parseWatermarkFromHeaderXml,
resolvePageBackgroundLayers,
} from "./doc-page-background.ts"
describe("doc-page-background", () => {
it("parses solid page color from w:background", () => {
const result = parseDocumentBackgroundXml(
`<w:background w:color="C6D9F1"><v:background fillcolor="#c6d9f1"/></w:background>`,
{}
)
assert.equal(result.pageColor, "#C6D9F1")
})
it("parses gradient fill css", () => {
const result = parseDocumentBackgroundXml(
`<w:background w:color="FFFFFF">
<v:background>
<v:fill type="gradient" color="#c0504d" color2="#f0504d" angle="45"/>
</v:background>
</w:background>`,
{}
)
assert.match(result.background?.gradientCss ?? "", /linear-gradient\(45deg/)
})
it("parses text watermark from header vml", () => {
const watermark = parseWatermarkFromHeaderXml(
`<w:hdr>
<w:p><w:r><w:pict>
<v:shape style="rotation:315" fillcolor="#d9d9d9">
<v:fill opacity=".35"/>
<v:textpath string="CONFIDENTIEL"/>
</v:shape>
</w:pict></w:r></w:p>
</w:hdr>`,
{},
"word/_rels/header1.xml.rels"
)
assert.equal(watermark?.kind, "text")
assert.equal(watermark?.text, "CONFIDENTIEL")
assert.equal(watermark?.rotationDeg, 315)
})
it("resolves background layers for rendering", () => {
const layers = resolvePageBackgroundLayers({
gradientCss: "linear-gradient(180deg, #fff, #eee)",
watermark: {
kind: "text",
text: "BROUILLON",
color: "#cccccc",
opacity: 0.4,
rotationDeg: -45,
},
})
assert.ok(layers.gradientCss)
assert.equal(layers.watermarkStyle?.text, "BROUILLON")
})
it("extracts background from document xml archive", () => {
const archive = {
"word/document.xml": new TextEncoder().encode(
`<w:document><w:background w:color="FFF2CC"/></w:document>`
),
}
const result = extractDocxPageBackground(archive, new TextDecoder().decode(archive["word/document.xml"]))
assert.equal(result.pageColor, "#FFF2CC")
})
})

View File

@ -0,0 +1,314 @@
type DocxArchive = Record<string, Uint8Array>
function parseWordColor(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 type DocPageFillImage = {
src: string
mode: "tile" | "frame" | "cover"
}
export type DocPageWatermark = {
kind: "text" | "image"
text?: string
src?: string
color?: string
opacity?: number
rotationDeg?: number
}
export type DocPageBackground = {
fillImage?: DocPageFillImage | null
watermark?: DocPageWatermark | null
gradientCss?: string | null
}
export type DocPageBackgroundLayers = {
fillImageStyle?: {
backgroundImage: string
backgroundSize: string
backgroundRepeat: string
backgroundPosition: string
}
watermarkStyle?: {
text?: string
imageSrc?: string
color: string
opacity: number
rotationDeg: number
}
gradientCss?: string
}
function decodeXml(bytes: Uint8Array | undefined): string {
if (!bytes) return ""
return new TextDecoder().decode(bytes)
}
export function parseDocxRelationships(relsXml: string): Map<string, string> {
const map = new Map<string, string>()
for (const match of relsXml.matchAll(/<Relationship\b[^>]*\/?>/gi)) {
const id = match[0].match(/\bId="([^"]+)"/i)?.[1]
const target = match[0].match(/\bTarget="([^"]+)"/i)?.[1]
if (id && target) map.set(id, target)
}
return map
}
function mimeFromMediaPath(path: string): string {
const ext = path.split(".").pop()?.toLowerCase()
switch (ext) {
case "png":
return "image/png"
case "jpg":
case "jpeg":
return "image/jpeg"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
case "bmp":
return "image/bmp"
default:
return "application/octet-stream"
}
}
export function uint8ArrayToDataUrl(bytes: Uint8Array, mime: string): string {
let binary = ""
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!)
}
const base64 = typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64")
return `data:${mime};base64,${base64}`
}
function resolveRelationshipTarget(baseDir: string, target: string): string {
if (target.startsWith("/")) return target.slice(1)
const baseParts = baseDir.split("/").filter(Boolean)
for (const part of target.split("/")) {
if (part === "..") baseParts.pop()
else if (part !== ".") baseParts.push(part)
}
return baseParts.join("/")
}
export function resolveDocxMediaDataUrl(
archive: DocxArchive,
relsPath: string,
rId: string
): string | null {
const relsXml = decodeXml(archive[relsPath])
if (!relsXml) return null
const target = parseDocxRelationships(relsXml).get(rId)
if (!target) return null
const baseDir = relsPath.replace(/\/_rels\/[^/]+\.rels$/, "")
const mediaPath = resolveRelationshipTarget(baseDir, target)
const bytes = archive[mediaPath]
if (!bytes) return null
return uint8ArrayToDataUrl(bytes, mimeFromMediaPath(mediaPath))
}
function parseVmlColor(raw: string | undefined): string | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (!trimmed) return undefined
if (trimmed.startsWith("#")) return trimmed.length === 7 ? trimmed : undefined
if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`
return parseWordColor(trimmed)
}
function parseFillImageMode(fillTag: string): DocPageFillImage["mode"] {
const type = fillTag.match(/\btype="([^"]+)"/i)?.[1]?.toLowerCase()
if (type === "tile") return "tile"
if (type === "frame") return "frame"
return "cover"
}
function parseGradientCss(fillTag: string): string | null {
const type = fillTag.match(/\btype="([^"]+)"/i)?.[1]?.toLowerCase()
const color = parseVmlColor(fillTag.match(/\bcolor="([^"]+)"/i)?.[1])
const color2 = parseVmlColor(fillTag.match(/\bcolor2="([^"]+)"/i)?.[1])
if (!color || !color2) return null
const angleRaw = fillTag.match(/\bangle="([^"]+)"/i)?.[1]
const angle = angleRaw ? Number.parseFloat(angleRaw) : 0
if (type === "gradientradial") {
return `radial-gradient(circle, ${color}, ${color2})`
}
if (type === "gradient") {
return `linear-gradient(${Number.isFinite(angle) ? angle : 0}deg, ${color}, ${color2})`
}
return null
}
export function parseDocumentBackgroundXml(
documentXml: string,
archive: DocxArchive
): { pageColor?: string; background?: DocPageBackground | null } {
const block =
documentXml.match(/<w:background\b[^>]*>[\s\S]*?<\/w:background>/i)?.[0] ??
documentXml.match(/<w:background\b[^>]*\/>/i)?.[0]
if (!block) return {}
const pageColor = parseWordColor(block.match(/\bw:color="([^"]+)"/i)?.[1])
const fillColor = parseVmlColor(block.match(/\bfillcolor="([^"]+)"/i)?.[1])
const resolvedColor =
pageColor !== "#000000" || block.includes('w:color="')
? pageColor
: fillColor ?? pageColor
const fillTag = block.match(/<v:fill\b[^>]*\/?>/i)?.[0]
let background: DocPageBackground | null = null
if (fillTag) {
const gradientCss = parseGradientCss(fillTag)
if (gradientCss) {
background = { gradientCss }
}
const rId = fillTag.match(/\br:id="([^"]+)"/i)?.[1]
if (rId) {
const src = resolveDocxMediaDataUrl(archive, "word/_rels/document.xml.rels", rId)
if (src) {
background = {
...(background ?? {}),
fillImage: { src, mode: parseFillImageMode(fillTag) },
}
}
}
}
return {
pageColor: resolvedColor !== "#000000" || block.includes('w:color="') ? resolvedColor : undefined,
background,
}
}
function parseRotationDeg(style: string | undefined): number {
if (!style) return -35
const rotation = style.match(/(?:rotation|rotate):\s*(-?\d+(?:\.\d+)?)/i)?.[1]
if (!rotation) return -35
const value = Number.parseFloat(rotation)
return Number.isFinite(value) ? value : -35
}
function parseOpacity(raw: string | undefined): number {
if (!raw) return 0.35
const value = Number.parseFloat(raw)
if (!Number.isFinite(value)) return 0.35
return Math.min(1, Math.max(0, value > 1 ? value / 100 : value))
}
export function parseWatermarkFromHeaderXml(
headerXml: string,
archive: DocxArchive,
relsPath: string
): DocPageWatermark | null {
const shape = headerXml.match(/<v:shape\b[^>]*>[\s\S]*?<\/v:shape>/i)?.[0]
if (!shape) return null
const text =
shape.match(/<v:textpath\b[^>]*\bw:string="([^"]+)"/i)?.[1] ??
shape.match(/<v:textpath\b[^>]*\bstring="([^"]+)"/i)?.[1]
if (text && text.trim()) {
const style = shape.match(/\bstyle="([^"]+)"/i)?.[1]
const fill = shape.match(/<v:fill\b[^>]*\/?>/i)?.[0]
const color =
parseVmlColor(shape.match(/\bfillcolor="([^"]+)"/i)?.[1]) ??
parseVmlColor(fill?.match(/\bcolor="([^"]+)"/i)?.[1]) ??
"#b4b4b4"
return {
kind: "text",
text: text.trim(),
color,
opacity: parseOpacity(fill?.match(/\bopacity="([^"]+)"/i)?.[1]),
rotationDeg: parseRotationDeg(style),
}
}
const imagedata = shape.match(/<v:imagedata\b[^>]*\/?>/i)?.[0]
const rId = imagedata?.match(/\br:id="([^"]+)"/i)?.[1]
if (rId) {
const src = resolveDocxMediaDataUrl(archive, relsPath, rId)
if (src) {
return {
kind: "image",
src,
opacity: parseOpacity(shape.match(/<v:fill\b[^>]*\bopacity="([^"]+)"/i)?.[1]),
rotationDeg: parseRotationDeg(shape.match(/\bstyle="([^"]+)"/i)?.[1]),
}
}
}
return null
}
export function extractDocxPageBackground(
archive: DocxArchive,
documentXml: string
): { pageColor?: string; background?: DocPageBackground | null } {
const fromDocument = parseDocumentBackgroundXml(documentXml, archive)
let watermark: DocPageWatermark | null = null
for (const key of Object.keys(archive)) {
const headerMatch = key.match(/^word\/(header\d+)\.xml$/)
if (!headerMatch) continue
const headerName = headerMatch[1]!
const relsPath = `word/_rels/${headerName}.xml.rels`
const parsed = parseWatermarkFromHeaderXml(decodeXml(archive[key]), archive, relsPath)
if (parsed) {
watermark = parsed
break
}
}
const background: DocPageBackground | null =
fromDocument.background || watermark
? {
...(fromDocument.background ?? {}),
...(watermark ? { watermark } : {}),
}
: null
return {
pageColor: fromDocument.pageColor,
background,
}
}
export function resolvePageBackgroundLayers(
background: DocPageBackground | null | undefined
): DocPageBackgroundLayers {
if (!background) return {}
const layers: DocPageBackgroundLayers = {}
if (background.gradientCss) {
layers.gradientCss = background.gradientCss
}
if (background.fillImage?.src) {
const mode = background.fillImage.mode
layers.fillImageStyle = {
backgroundImage: `url("${background.fillImage.src}")`,
backgroundRepeat: mode === "tile" ? "repeat" : "no-repeat",
backgroundSize: mode === "tile" ? "auto" : mode === "frame" ? "100% 100%" : "cover",
backgroundPosition: "center",
}
}
if (background.watermark) {
layers.watermarkStyle = {
text: background.watermark.kind === "text" ? background.watermark.text : undefined,
imageSrc: background.watermark.kind === "image" ? background.watermark.src : undefined,
color: background.watermark.color ?? "#b4b4b4",
opacity: background.watermark.opacity ?? 0.35,
rotationDeg: background.watermark.rotationDeg ?? -35,
}
}
return layers
}

View File

@ -0,0 +1,135 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import {
borderSzToPx,
bordersToLayoutCss,
buildPageSetupFromDraft,
cmToMm,
draftFromPageSetup,
matchPageFormatId,
mmToCm,
parseWordBorderSide,
resolveDocumentPageLayout,
twipsToMm,
} from "./doc-page-setup.ts"
describe("doc-page-setup", () => {
it("converts A4 twips to millimeters", () => {
assert.ok(Math.abs(twipsToMm(11906) - 210) < 1)
assert.ok(Math.abs(twipsToMm(16838) - 297) < 1)
})
it("matches known page presets", () => {
assert.equal(matchPageFormatId(210, 297), "a4")
assert.equal(matchPageFormatId(216, 279), "letter")
})
it("applies imported margins and custom size", () => {
const layout = resolveDocumentPageLayout({
widthMm: 210,
heightMm: 297,
marginsMm: { top: 25.4, right: 20, bottom: 25.4, left: 20 },
formatId: "a4",
})
assert.equal(layout.format.id, "a4")
assert.ok(layout.marginsPx.left < layout.marginsPx.top)
})
it("maps Word border sides to CSS", () => {
const side = parseWordBorderSide('<w:top w:val="single" w:sz="12" w:color="FF0000"/>')
assert.equal(side.style, "solid")
assert.equal(side.color, "#FF0000")
assert.ok(borderSzToPx(12) >= 1)
const layout = bordersToLayoutCss({
top: side,
right: side,
bottom: side,
left: side,
offsetFrom: "page",
})
assert.ok(layout.sheetBorderCss?.top?.includes("solid"))
assert.equal(layout.textAreaBorderCss, undefined)
})
it("renders text-offset borders inside margins", () => {
const side = parseWordBorderSide('<w:left w:val="double" w:sz="8" w:color="000000"/>')
const layout = bordersToLayoutCss({
top: { style: "none", widthPx: 0, color: "#000000" },
right: { style: "none", widthPx: 0, color: "#000000" },
bottom: { style: "none", widthPx: 0, color: "#000000" },
left: side,
offsetFrom: "text",
})
assert.ok(layout.textAreaBorderCss?.left?.includes("double"))
assert.equal(layout.sheetBorderCss, undefined)
})
it("builds draft with margins in centimeters", () => {
const draft = draftFromPageSetup(
{
widthMm: 210,
heightMm: 297,
marginsMm: { top: 20, right: 20, bottom: 20, left: 20 },
formatId: "a4",
orientation: "portrait",
},
"letter"
)
assert.equal(draft.formatId, "a4")
assert.equal(draft.marginsCm.top, 2)
assert.equal(draft.orientation, "portrait")
})
it("applies margins orientation and preserves imported borders", () => {
const importedBorders = {
top: { style: "solid" as const, widthPx: 2, color: "#000000" },
right: { style: "solid" as const, widthPx: 2, color: "#000000" },
bottom: { style: "solid" as const, widthPx: 2, color: "#000000" },
left: { style: "solid" as const, widthPx: 2, color: "#000000" },
offsetFrom: "text" as const,
}
const setup = buildPageSetupFromDraft(
{
formatId: "a4",
orientation: "landscape",
marginsCm: { top: 2.5, right: 2, bottom: 2.5, left: 2 },
pageColor: "#fff8e1",
},
{
widthMm: 210,
heightMm: 297,
marginsMm: { top: 20, right: 20, bottom: 20, left: 20 },
formatId: "a4",
borders: importedBorders,
}
)
assert.equal(setup.widthMm, 297)
assert.equal(setup.heightMm, 210)
assert.equal(setup.orientation, "landscape")
assert.equal(setup.marginsMm.top, cmToMm(2.5))
assert.equal(setup.pageColor, "#fff8e1")
assert.equal(setup.borders, importedBorders)
})
it("preserves margins when changing format from menubar", () => {
const setup = buildPageSetupFromDraft(
{
formatId: "a4",
orientation: "portrait",
marginsCm: { top: 3, right: 2.5, bottom: 3, left: 2.5 },
pageColor: "#ffffff",
},
null
)
const letter = buildPageSetupFromDraft(
{ ...draftFromPageSetup(setup, "a4"), formatId: "letter" },
setup
)
assert.equal(letter.formatId, "letter")
assert.equal(letter.marginsMm.left, cmToMm(2.5))
assert.equal(mmToCm(letter.marginsMm.left), 2.5)
})
})

398
lib/drive/doc-page-setup.ts Normal file
View File

@ -0,0 +1,398 @@
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
}
}

View File

@ -0,0 +1,16 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { pageSetupDraftsEqual, systemPageSetupDraft } from "./docs-page-defaults.ts"
describe("docs-page-defaults", () => {
it("compares page setup drafts", () => {
const base = systemPageSetupDraft("a4")
assert.ok(pageSetupDraftsEqual(base, { ...base }))
assert.ok(
!pageSetupDraftsEqual(base, {
...base,
marginsCm: { ...base.marginsCm, top: base.marginsCm.top + 0.1 },
})
)
})
})

View File

@ -0,0 +1,59 @@
import {
draftFromPageSetup,
type PageSetupDraft,
} from "./doc-page-setup.ts"
import { DEFAULT_PAGE_FORMAT_ID, type PageFormatId } from "./page-formats.ts"
const STORAGE_KEY = "ultidrive-docs-page-defaults"
export function systemPageSetupDraft(
fallbackFormatId: PageFormatId = DEFAULT_PAGE_FORMAT_ID
): PageSetupDraft {
return draftFromPageSetup(null, fallbackFormatId)
}
function readStoredDefaults(): PageSetupDraft | null {
if (typeof localStorage === "undefined") return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<PageSetupDraft>
if (!parsed.formatId || !parsed.marginsCm) return null
return {
formatId: parsed.formatId,
orientation: parsed.orientation === "landscape" ? "landscape" : "portrait",
marginsCm: {
top: Number(parsed.marginsCm.top) || 0,
right: Number(parsed.marginsCm.right) || 0,
bottom: Number(parsed.marginsCm.bottom) || 0,
left: Number(parsed.marginsCm.left) || 0,
},
pageColor: parsed.pageColor ?? "#ffffff",
}
} catch {
return null
}
}
export function readUserPageSetupDefaults(
fallbackFormatId: PageFormatId = DEFAULT_PAGE_FORMAT_ID
): PageSetupDraft {
return readStoredDefaults() ?? systemPageSetupDraft(fallbackFormatId)
}
export function saveUserPageSetupDefaults(draft: PageSetupDraft): void {
if (typeof localStorage === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(draft))
}
export function pageSetupDraftsEqual(a: PageSetupDraft, b: PageSetupDraft): boolean {
return (
a.formatId === b.formatId &&
a.orientation === b.orientation &&
a.pageColor.toLowerCase() === b.pageColor.toLowerCase() &&
a.marginsCm.top === b.marginsCm.top &&
a.marginsCm.right === b.marginsCm.right &&
a.marginsCm.bottom === b.marginsCm.bottom &&
a.marginsCm.left === b.marginsCm.left
)
}

View File

@ -8,7 +8,7 @@ export type PageFormat = {
} }
/** 96 CSS px per inch, 25.4 mm per inch */ /** 96 CSS px per inch, 25.4 mm per inch */
const MM_TO_PX = 96 / 25.4 export const MM_TO_PX = 96 / 25.4
export const PAGE_FORMATS: PageFormat[] = [ export const PAGE_FORMATS: PageFormat[] = [
{ id: "a4", label: "A4", widthMm: 210, heightMm: 297 }, { id: "a4", label: "A4", widthMm: 210, heightMm: 297 },

View File

@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { isEmptyTipTapDoc } from "./richtext-content.ts"
describe("richtext-content", () => {
it("detects empty TipTap documents", () => {
assert.equal(isEmptyTipTapDoc(undefined), true)
assert.equal(
isEmptyTipTapDoc({ type: "doc", content: [{ type: "paragraph" }] }),
true
)
assert.equal(
isEmptyTipTapDoc({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }],
}),
false
)
})
})

View File

@ -0,0 +1,25 @@
type TipTapNode = {
type?: string
text?: string
content?: TipTapNode[]
}
/** True when TipTap JSON has no meaningful text (blank doc / empty paragraph). */
export function isEmptyTipTapDoc(content: Record<string, unknown> | null | undefined): boolean {
if (!content) return true
return !tipTapNodeHasText(content as TipTapNode)
}
function tipTapNodeHasText(node: TipTapNode | TipTapNode[] | null | undefined): boolean {
if (!node) return false
if (Array.isArray(node)) {
return node.some((child) => tipTapNodeHasText(child))
}
if (typeof node.text === "string" && node.text.trim() !== "") {
return true
}
if (Array.isArray(node.content)) {
return node.content.some((child) => tipTapNodeHasText(child))
}
return false
}

View File

@ -1,7 +1,86 @@
import mammoth from "mammoth" import mammoth from "mammoth"
import {
extractDocxPageSetup,
type DocPageSetup,
} from "@/lib/drive/doc-page-setup"
export type TipTapJSON = Record<string, unknown> export type TipTapJSON = Record<string, unknown>
export type RichTextImportResult = {
content: TipTapJSON
pageSetup?: DocPageSetup | null
}
const IMAGE_ATTR_KEYS = ["src", "alt", "title", "width", "height"] as const
function imageNodeFromElement(el: HTMLElement): TipTapJSON | null {
const src = el.getAttribute("src")
if (!src) return null
const attrs: Record<string, unknown> = {
src,
alt: el.getAttribute("alt") ?? "",
}
const title = el.getAttribute("title")
if (title) attrs.title = title
const width = el.getAttribute("width")
const height = el.getAttribute("height")
if (width) attrs.width = Number(width) || width
if (height) attrs.height = Number(height) || height
return { type: "image", attrs }
}
function inlineContentFromElement(el: HTMLElement): TipTapJSON[] {
const content: TipTapJSON[] = []
for (const node of el.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (text) content.push({ type: "text", text })
continue
}
if (node.nodeType !== Node.ELEMENT_NODE) continue
const child = node as HTMLElement
const tag = child.tagName.toLowerCase()
if (tag === "img") {
const image = imageNodeFromElement(child)
if (image) content.push(image)
continue
}
content.push(...inlineContentFromElement(child))
}
return content
}
/** Keep only attrs supported by @tiptap/extension-image. */
export function normalizeImportedTipTap(content: TipTapJSON): TipTapJSON {
const walk = (node: unknown): unknown => {
if (!node || typeof node !== "object") return node
if (Array.isArray(node)) return node.map(walk)
const record = node as TipTapJSON
if (record.type === "image" && record.attrs && typeof record.attrs === "object") {
const raw = record.attrs as Record<string, unknown>
const attrs: Record<string, unknown> = {}
for (const key of IMAGE_ATTR_KEYS) {
const value = raw[key]
if (value != null && value !== "") attrs[key] = value
}
if (typeof attrs.src !== "string" || !attrs.src) {
return null
}
return { ...record, attrs }
}
if (Array.isArray(record.content)) {
const nextContent = record.content.map(walk).filter(Boolean)
return { ...record, content: nextContent }
}
return record
}
const normalized = walk(content)
if (!normalized || typeof normalized !== "object") {
return { type: "doc", content: [{ type: "paragraph" }] }
}
return normalized as TipTapJSON
}
function htmlToTipTapDoc(html: string): TipTapJSON { function htmlToTipTapDoc(html: string): TipTapJSON {
const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null const parser = typeof DOMParser !== "undefined" ? new DOMParser() : null
if (!parser) { if (!parser) {
@ -24,11 +103,18 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
if (node.nodeType !== Node.ELEMENT_NODE) return if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement const el = node as HTMLElement
const tag = el.tagName.toLowerCase() const tag = el.tagName.toLowerCase()
if (tag === "img") {
const image = imageNodeFromElement(el)
if (image) {
blocks.push({ type: "paragraph", content: [image] })
}
return
}
if (tag === "p" || tag === "div") { if (tag === "p" || tag === "div") {
const text = el.textContent ?? "" const content = inlineContentFromElement(el)
blocks.push({ blocks.push({
type: "paragraph", type: "paragraph",
content: text ? [{ type: "text", text }] : [], content: content.length ? content : [],
}) })
return return
} }
@ -45,7 +131,7 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
const listType = tag === "ul" ? "bulletList" : "orderedList" const listType = tag === "ul" ? "bulletList" : "orderedList"
const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({ const items = Array.from(el.querySelectorAll(":scope > li")).map((li) => ({
type: "listItem", type: "listItem",
content: [{ type: "paragraph", content: [{ type: "text", text: li.textContent ?? "" }] }], content: [{ type: "paragraph", content: inlineContentFromElement(li) }],
})) }))
blocks.push({ type: listType, content: items }) blocks.push({ type: listType, content: items })
return return
@ -57,21 +143,33 @@ function htmlToTipTapDoc(html: string): TipTapJSON {
if (blocks.length === 0) { if (blocks.length === 0) {
blocks.push({ type: "paragraph" }) blocks.push({ type: "paragraph" })
} }
return { type: "doc", content: blocks } return normalizeImportedTipTap({ type: "doc", content: blocks })
} }
export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<TipTapJSON> { export async function importDocxToTipTap(buffer: ArrayBuffer): Promise<RichTextImportResult> {
const pageSetup = await extractDocxPageSetup(buffer)
try { try {
const { parseDOCX } = await import("@docen/import-docx") const { parseDOCX } = await import("@docen/import-docx")
const content = await parseDOCX(buffer) const content = await parseDOCX(buffer, { image: { crop: false } })
if (content && typeof content === "object") { if (content && typeof content === "object") {
return content as TipTapJSON return { content: normalizeImportedTipTap(content as TipTapJSON), pageSetup }
}
} catch (error) {
if (process.env.NODE_ENV !== "production") {
console.warn("[richtext-import] parseDOCX failed, falling back to mammoth", error)
} }
} catch {
/* fallback mammoth */
} }
const result = await mammoth.convertToHtml({ arrayBuffer: buffer }) const result = await mammoth.convertToHtml(
return htmlToTipTapDoc(result.value) { arrayBuffer: buffer },
{
convertImage: mammoth.images.imgElement((image) =>
image.read("base64").then((imageBuffer) => ({
src: `data:${image.contentType};base64,${imageBuffer}`,
}))
),
}
)
return { content: htmlToTipTapDoc(result.value), pageSetup }
} }
export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> { export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
@ -88,21 +186,23 @@ export async function exportTipTapToDocx(content: TipTapJSON): Promise<Blob> {
export async function importFileToTipTap( export async function importFileToTipTap(
fileName: string, fileName: string,
buffer: ArrayBuffer buffer: ArrayBuffer
): Promise<TipTapJSON> { ): Promise<RichTextImportResult> {
const ext = fileName.split(".").pop()?.toLowerCase() ?? "" const ext = fileName.split(".").pop()?.toLowerCase() ?? ""
if (ext === "docx" || ext === "docm") { if (ext === "docx" || ext === "docm") {
return importDocxToTipTap(buffer) return importDocxToTipTap(buffer)
} }
const text = new TextDecoder().decode(buffer) const text = new TextDecoder().decode(buffer)
if (ext === "html" || ext === "htm") { if (ext === "html" || ext === "htm") {
return htmlToTipTapDoc(text) return { content: htmlToTipTapDoc(text) }
} }
const lines = text.split(/\r?\n/) const lines = text.split(/\r?\n/)
return { return {
type: "doc", content: {
content: lines.map((line) => ({ type: "doc",
type: "paragraph", content: lines.map((line) => ({
content: line ? [{ type: "text", text: line }] : [], type: "paragraph",
})), content: line ? [{ type: "text", text: line }] : [],
})),
},
} }
} }

View File

@ -1,3 +1,5 @@
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
export type RichTextSessionResponse = { export type RichTextSessionResponse = {
roomId: string roomId: string
canonicalPath: string canonicalPath: string
@ -9,6 +11,7 @@ export type RichTextSessionResponse = {
collaboration: boolean collaboration: boolean
documentUrl?: string documentUrl?: string
saveUrl?: string saveUrl?: string
pageSetup?: DocPageSetup | null
} }
export type RichTextSaveStatus = "saved" | "saving" | "error" | "idle" export type RichTextSaveStatus = "saved" | "saving" | "error" | "idle"

View File

@ -23,6 +23,7 @@ import {
} from "@/lib/drive/docs-file-menu-export" } from "@/lib/drive/docs-file-menu-export"
import { openRichTextDocument } from "@/lib/drive/open-rich-text-document" import { openRichTextDocument } from "@/lib/drive/open-rich-text-document"
import type { PageFormatId } from "@/lib/drive/page-formats" import type { PageFormatId } from "@/lib/drive/page-formats"
import type { DocPageSetup } from "@/lib/drive/doc-page-setup"
import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store" import { useDocsKeyboardShortcutsStore } from "@/lib/stores/docs-keyboard-shortcuts-store"
function buildCopyFileName(originalName: string, siblingNames: string[]): string { function buildCopyFileName(originalName: string, siblingNames: string[]): string {
@ -45,20 +46,24 @@ function buildCopyFileName(originalName: string, siblingNames: string[]): string
export function useDocsFileMenu({ export function useDocsFileMenu({
file, file,
editor, editor,
pageFormatId, pageSetup,
onPageFormatChange, fallbackFormatId,
onPageSetupApply,
onShareClick, onShareClick,
onRenameRequest, onRenameRequest,
onFileMoved, onFileMoved,
onPurgeSidecarAndReimport,
disabled, disabled,
}: { }: {
file?: DriveFileInfo file?: DriveFileInfo
editor: Editor | null editor: Editor | null
pageFormatId: PageFormatId pageSetup: DocPageSetup | null
onPageFormatChange: (id: PageFormatId) => void fallbackFormatId: PageFormatId
onPageSetupApply: (setup: DocPageSetup) => void
onShareClick?: () => void onShareClick?: () => void
onRenameRequest?: () => void onRenameRequest?: () => void
onFileMoved?: (newPath: string) => void onFileMoved?: (newPath: string) => void
onPurgeSidecarAndReimport?: () => void
disabled?: boolean disabled?: boolean
}) { }) {
const router = useRouter() const router = useRouter()
@ -192,11 +197,15 @@ export function useDocsFileMenu({
onSecurityLimits: () => soon("Limites de sécurité"), onSecurityLimits: () => soon("Limites de sécurité"),
onPageSetup: () => setPageSetupOpen(true), onPageSetup: () => setPageSetupOpen(true),
onPrint: () => window.print(), onPrint: () => window.print(),
...(onPurgeSidecarAndReimport
? { onPurgeSidecarAndReimport: () => void onPurgeSidecarAndReimport() }
: {}),
}), }),
[ [
downloadFormat, downloadFormat,
makeCopy, makeCopy,
moveToTrash, moveToTrash,
onPurgeSidecarAndReimport,
onRenameRequest, onRenameRequest,
onShareClick, onShareClick,
siblingNames, siblingNames,
@ -260,8 +269,9 @@ export function useDocsFileMenu({
<DocsPageSetupDialog <DocsPageSetupDialog
open={pageSetupOpen} open={pageSetupOpen}
onOpenChange={setPageSetupOpen} onOpenChange={setPageSetupOpen}
pageFormatId={pageFormatId} pageSetup={pageSetup}
onPageFormatChange={onPageFormatChange} fallbackFormatId={fallbackFormatId}
onApply={onPageSetupApply}
/> />
<DocsDetailsDialog open={detailsOpen} onOpenChange={setDetailsOpen} file={file} /> <DocsDetailsDialog open={detailsOpen} onOpenChange={setDetailsOpen} file={file} />
</> </>

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --hostname 0.0.0.0 --webpack", "dev": "next dev --hostname 0.0.0.0 --port 3004 --webpack",
"dev:turbo": "next dev --hostname 0.0.0.0", "dev:turbo": "next dev --hostname 0.0.0.0 --port 3004",
"dev:webpack": "next dev --hostname 0.0.0.0 --webpack", "dev:webpack": "next dev --hostname 0.0.0.0 --port 3004 --webpack",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
@ -94,6 +94,7 @@
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"embla-carousel-react": "8.6.0", "embla-carousel-react": "8.6.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"fflate": "^0.8.3",
"fuse.js": "^7.3.0", "fuse.js": "^7.3.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"input-otp": "1.4.2", "input-otp": "1.4.2",

View File

@ -227,6 +227,9 @@ importers:
emoji-mart: emoji-mart:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.6.0 version: 5.6.0
fflate:
specifier: ^0.8.3
version: 0.8.3
fuse.js: fuse.js:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0 version: 7.3.0
@ -2059,6 +2062,9 @@ packages:
fflate@0.8.2: fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@ -4323,6 +4329,8 @@ snapshots:
fflate@0.8.2: {} fflate@0.8.2: {}
fflate@0.8.3: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fsevents@2.3.2: fsevents@2.3.2:

View File

@ -31,6 +31,11 @@
width: 100%; width: 100%;
} }
.ultidrive-richtext-editor img {
max-width: 100%;
height: auto;
}
.ultidrive-richtext-editor td, .ultidrive-richtext-editor td,
.ultidrive-richtext-editor th { .ultidrive-richtext-editor th {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
@ -456,6 +461,10 @@ html.dark .docs-menu-item-icon {
border: 1px solid #dadce0; border: 1px solid #dadce0;
} }
.ultidrive-docs-page--imported-border {
border: none;
}
.ultidrive-docs-page .ultidrive-richtext-editor { .ultidrive-docs-page .ultidrive-richtext-editor {
min-height: 100%; min-height: 100%;
color: #000000; color: #000000;

File diff suppressed because one or more lines are too long