From fa11034a9971c3e7c4b564e1fb059ae489585425 Mon Sep 17 00:00:00 2001 From: Matheus Saito <106726276+MattSaito@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:53:33 -0300 Subject: [PATCH] Added Measurement Scale Support for Architectural Drawings (#6121) (#6215) Fix #6121 # Description of Changes This PR expands the viewer ruler/measurement tool with real-world scale support. Users can now apply preset scales, define custom scales, calibrate a scale by drawing a reference measurement and entering its known real-world distance, and view measurements with scaled real-world values. It also refactors PDF `/Measure` and `/VP` scale extraction out of `EmbedPdfViewer` into a dedicated utility, centralizes ruler state management in a dedicated hook, persists ruler measurements and selected scales per file during the browser session, remembers the last calibration unit locally, and updates the ruler overlay so measurements remain aligned with the PDF page during rotation and scrolling. **New Files** - `RulerMeasurementLayer.tsx` - Renders ruler measurements in the SVG overlay, including lines, points, labels, page/scaled values, delete controls, live previews, clear controls, and label visibility modes. - `RulerScaleSettingsButton.tsx` - Adds the scale settings button/popover to the viewer toolbar. - `ScaleCalibrationDialog.tsx` - Provides the calibration modal where users enter a known real-world distance to calculate the scale automatically. - `ScaleSettingsPanel.tsx` - Provides preset scales, custom scale input, calibration entry point, active scale display, and reset controls. - `useMeasurementManager.ts` - Centralizes ruler state, custom scale state, calibration flow, per-file measurements, session persistence, and loading of PDF-derived scale data. - `measurementPreferences.ts` - Persists the last calibration unit in `localStorage`. - `measurementTypes.ts` - Defines shared measurement, point, scale, page scale, and viewport scale types. - `measurementUtils.ts` - Provides unit conversion, scale calculation, validation, formatting, calibration helpers, and session storage helpers. - `measurementUtils.test.ts` - Adds unit tests for scale calculations, unit conversion, preset parsing, ratio derivation, and calibration. - `pdfMeasurementExtraction.ts` - Moves PDF `/Measure` and `/VP` scale extraction into a dedicated utility. **Changed Files** - `EmbedPdfViewer.tsx` - Removes inline PDF scale extraction and delegates ruler/measurement state to `useMeasurementManager`; integrates the ruler overlay, custom scale support, restored measurements, and calibration dialog. - `LocalEmbedPDF.tsx` - Adds page-level metadata used by the ruler overlay, including page width, height, and native page rotation. - `RotateAPIBridge.tsx` - Adds immediate rotation update propagation so ruler measurements can update their page-anchored positions during rotation changes. - `RulerOverlay.tsx` - Refactors the ruler overlay to use shared measurement types/utilities, support custom scales, calibration measurements, restored measurements, measurement change listeners, rotation-aware positioning, and scroll compensation, also holding Alt key will activate pass-through behavior so labels do not block ruler interactions. - `useViewerWorkbenchBarButtons.tsx` - Adds the ruler scale settings action and coordinates ruler, pan mode, and calibration behavior. - `ViewerContext.tsx` - Adds immediate rotation notification support used by ruler measurements while viewer rotation changes are applied. - `en-GB/translation.toml` and `en-US/translation.toml` - Add UI text for scale settings, calibration actions, ruler measurement values, and ruler label controls. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) Current scale panel : Captura de tela de 2026-05-31
21-57-53 Current calibration input : Captura de tela de 2026-05-31
21-59-52 Example of usage : Captura de tela de 2026-05-31
22-31-01 ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/en-GB/translation.toml | 35 + .../public/locales/en-US/translation.toml | 35 + .../core/components/viewer/EmbedPdfViewer.tsx | 174 +-- .../core/components/viewer/LocalEmbedPDF.tsx | 81 +- .../components/viewer/RotateAPIBridge.tsx | 17 +- .../viewer/RulerMeasurementLayer.tsx | 950 ++++++++++++ .../core/components/viewer/RulerOverlay.tsx | 1295 ++++++++--------- .../viewer/RulerScaleSettingsButton.tsx | 82 ++ .../viewer/ScaleCalibrationDialog.tsx | 190 +++ .../components/viewer/ScaleSettingsPanel.tsx | 321 ++++ .../viewer/useViewerWorkbenchBarButtons.tsx | 66 + .../src/core/contexts/ViewerContext.tsx | 17 + .../src/core/hooks/useMeasurementManager.ts | 293 ++++ .../src/core/utils/measurementPreferences.ts | 24 + .../editor/src/core/utils/measurementTypes.ts | 45 + .../src/core/utils/measurementUtils.test.ts | 116 ++ .../editor/src/core/utils/measurementUtils.ts | 398 +++++ .../core/utils/pdfMeasurementExtraction.ts | 215 +++ 18 files changed, 3536 insertions(+), 818 deletions(-) create mode 100644 frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx create mode 100644 frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx create mode 100644 frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx create mode 100644 frontend/editor/src/core/components/viewer/ScaleSettingsPanel.tsx create mode 100644 frontend/editor/src/core/hooks/useMeasurementManager.ts create mode 100644 frontend/editor/src/core/utils/measurementPreferences.ts create mode 100644 frontend/editor/src/core/utils/measurementTypes.ts create mode 100644 frontend/editor/src/core/utils/measurementUtils.test.ts create mode 100644 frontend/editor/src/core/utils/measurementUtils.ts create mode 100644 frontend/editor/src/core/utils/pdfMeasurementExtraction.ts diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index fba28f6872..2bf1d347b6 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -8631,6 +8631,14 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments [rotate.tooltip.header] title = "Rotate Settings Overview" +[ruler] +clearAll = "Clear all" +hideAllLabels = "Hide all labels" +hideSmallLabels = "Hide small labels" +physicalValues = "Physical" +scaled = "Scaled" +showAllLabels = "Show all labels" + [sanitize] desc = "Remove potentially harmful elements from PDF files." sanitizationResults = "Sanitisation Results" @@ -8682,6 +8690,32 @@ title = "Sanitise PDF" tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size" title = "Adjust page-scale" +[scaleSettings] +activeScale = "Active Scale" +apply = "Apply Scale" +applyCalibration = "Apply Calibration" +calculatedScale = "Calculated scale" +calibrate = "Calibrate" +calibrating = "Calibrating" +calibrationDistanceRequired = "Enter a real-world distance greater than zero" +calibrationInvalid = "Unable to calculate scale from this measurement" +calibrationPaperDistance = "Measured page distance: {{distance}}" +calibrationTitle = "Calibrate Scale" +calibrationTooltip = "Measure a known distance to calculate the scale automatically" +customScale = "Custom Scale" +noneSet = "No custom scale set" +presets = "Preset Scales" +ratio = "Scale Ratio" +ratioHelp = "Ratio: 1 page unit = X real-world units" +ratioPlaceholder = "e.g., 100" +ratioPositive = "Ratio must be greater than zero" +ratioRequired = "Ratio is required" +realDistance = "Real-world distance" +realDistancePlaceholder = "e.g., 5" +reset = "Reset to Defaults" +unit = "Unit" +unitInvalid = "Invalid unit: {{scaleUnit}}" + [scannerEffect] tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" @@ -10670,6 +10704,7 @@ redact = "Redact" rotateLeft = "Rotate Left" rotateRight = "Rotate Right" ruler = "Ruler / Measure" +rulerSettings = "Scale Settings" save = "Save" saveAll = "Save All" saveAs = "Save As" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index ea4f0399b9..4e4abe1ab7 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -9555,6 +9555,14 @@ text = "Rotate your PDF pages clockwise or counterclockwise in 90-degree increme [rotate.tooltip.header] title = "Rotate Settings Overview" +[ruler] +clearAll = "Clear all" +hideAllLabels = "Hide all labels" +hideSmallLabels = "Hide small labels" +physicalValues = "Physical" +scaled = "Scaled" +showAllLabels = "Show all labels" + [sanitize] desc = "Remove potentially harmful elements from PDF files." sanitizationResults = "Sanitization Results" @@ -9606,6 +9614,32 @@ tags = "clean,secure,safe,remove-threats" tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size" title = "Adjust page-scale" +[scaleSettings] +activeScale = "Active Scale" +apply = "Apply Scale" +applyCalibration = "Apply Calibration" +calculatedScale = "Calculated scale" +calibrate = "Calibrate" +calibrating = "Calibrating" +calibrationDistanceRequired = "Enter a real-world distance greater than zero" +calibrationInvalid = "Unable to calculate scale from this measurement" +calibrationPaperDistance = "Measured page distance: {{distance}}" +calibrationTitle = "Calibrate Scale" +calibrationTooltip = "Measure a known distance to calculate the scale automatically" +customScale = "Custom Scale" +noneSet = "No custom scale set" +presets = "Preset Scales" +ratio = "Scale Ratio" +ratioHelp = "Ratio: 1 page unit = X real-world units" +ratioPlaceholder = "e.g., 100" +ratioPositive = "Ratio must be greater than zero" +ratioRequired = "Ratio is required" +realDistance = "Real-world distance" +realDistancePlaceholder = "e.g., 5" +reset = "Reset to Defaults" +unit = "Unit" +unitInvalid = "Invalid unit: {{scaleUnit}}" + [scannerEffect] tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" @@ -11624,6 +11658,7 @@ redact = "Redact" rotateLeft = "Rotate Left" rotateRight = "Rotate Right" ruler = "Ruler / Measure" +rulerSettings = "Scale Settings" save = "Save" saveAll = "Save All" saveAs = "Save As" diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index be4a4a9971..39d751790f 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Box, Center, Text, Stack } from "@mantine/core"; import { Button } from "@app/ui/Button"; @@ -43,109 +37,17 @@ import { useViewerWorkbenchBarButtons } from "@app/components/viewer/useViewerWo import { StampPlacementOverlay } from "@app/components/viewer/StampPlacementOverlay"; import { RulerOverlay, - type PageMeasureScales, - type PageScaleInfo, - type ViewportScale, + type RulerOverlayHandle, } from "@app/components/viewer/RulerOverlay"; -import type { PDFDict, PDFNumber } from "@cantoo/pdf-lib"; import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; +import { useMeasurementManager } from "@app/hooks/useMeasurementManager"; +import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog"; import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { alert } from "@app/components/toast"; -// ─── Measure dictionary extraction ──────────────────────────────────────────── - -async function extractPageMeasureScales( - file: Blob, -): Promise { - try { - const { - PDFDocument, - PDFDict, - PDFName, - PDFArray, - PDFNumber, - PDFString, - PDFHexString, - } = await import("@cantoo/pdf-lib"); - const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { - ignoreEncryption: true, - }); - - // Parse a Measure dict into a MeasureScale, or return null if malformed. - const parseScale = (measureObj: unknown) => { - if (!(measureObj instanceof PDFDict)) return null; - // @cantoo/pdf-lib ships without individual .d.ts files so instanceof can't narrow `unknown` - const m = measureObj as PDFDict; - const rObj = m.lookup(PDFName.of("R")); - const ratioLabel = - rObj instanceof PDFString || rObj instanceof PDFHexString - ? rObj.decodeText() - : ""; - // D = distance array, X = x-axis fallback - let fmtArray = m.lookup(PDFName.of("D")); - if (!(fmtArray instanceof PDFArray)) fmtArray = m.lookup(PDFName.of("X")); - if (!(fmtArray instanceof PDFArray)) return null; - const firstFmt = fmtArray.lookup(0); - if (!(firstFmt instanceof PDFDict)) return null; - const cObj = firstFmt.lookup(PDFName.of("C")); - const uObj = firstFmt.lookup(PDFName.of("U")); - if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null; - const unit = - uObj instanceof PDFString || uObj instanceof PDFHexString - ? uObj.decodeText() - : "units"; - return { factor: cObj.asNumber(), unit, ratioLabel }; - }; - - const result: PageMeasureScales = new Map(); - - for (let i = 0; i < pdfDoc.getPageCount(); i++) { - const page = pdfDoc.getPage(i); - const pageHeight = page.getHeight(); - const pageNode = page.node as unknown as PDFDict; - const viewports: ViewportScale[] = []; - - // Spec-conformant: /VP array — each viewport can have its own scale and BBox - const vpObj = pageNode.lookup(PDFName.of("VP")); - if (vpObj instanceof PDFArray) { - for (let j = 0; j < vpObj.size(); j++) { - const vpEntry = vpObj.lookup(j); - if (!(vpEntry instanceof PDFDict)) continue; - const scale = parseScale(vpEntry.lookup(PDFName.of("Measure"))); - if (!scale) continue; - let bbox: ViewportScale["bbox"] = null; - const bboxObj = vpEntry.lookup(PDFName.of("BBox")); - if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) { - bbox = [ - (bboxObj.lookup(0) as PDFNumber).asNumber(), - (bboxObj.lookup(1) as PDFNumber).asNumber(), - (bboxObj.lookup(2) as PDFNumber).asNumber(), - (bboxObj.lookup(3) as PDFNumber).asNumber(), - ]; - } - viewports.push({ bbox, scale }); - } - } - - // Fallback: /Measure directly on page (non-conforming but seen in the wild) - if (viewports.length === 0) { - const scale = parseScale(pageNode.lookup(PDFName.of("Measure"))); - if (scale) viewports.push({ bbox: null, scale }); - } - - if (viewports.length > 0) - result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); - } - - return result.size > 0 ? result : null; - } catch { - return null; - } -} - // ────────────────────────────────────────────────────────────────────────────── export interface EmbedPdfViewerProps { @@ -378,6 +280,12 @@ const EmbedPdfViewerContent = ({ return null; }, [previewFile, activeFiles, activeFileId]); + // Namespaced identifier for form-fill state; keep this aligned with FormFill. + const currentFileId = React.useMemo( + () => getFormFillFileId(currentFile), + [currentFile], + ); + // Stable id — avoids blob URL churn when FileContext recreates file objects each render. const currentFileStableId = currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null; @@ -1148,28 +1056,37 @@ const EmbedPdfViewerContent = ({ }; }, [applyChanges, setApplyChanges]); - // Ruler / measurement tool state - const [isRulerActive, setIsRulerActive] = useState(false); - const [pageMeasureScales, setPageMeasureScales] = - useState(null); + // Ruler / measurement tool state is handled by the dedicated hook. + const rulerOverlayRef = useRef(null); - useEffect(() => { - const file = effectiveFile?.file; - if (!file) { - setPageMeasureScales(null); - return; - } - let cancelled = false; - extractPageMeasureScales(file).then((scales) => { - if (!cancelled) setPageMeasureScales(scales); - }); - return () => { - cancelled = true; - }; - }, [effectiveFile]); + const { + isRulerActive, + setIsRulerActive, + pageMeasureScales, + customScale, + handleSetCustomScale, + isScaleCalibrationActive, + scaleCalibrationMeasurement, + startScaleCalibration, + cancelScaleCalibration, + handleScaleCalibrationMeasurement, + applyScaleCalibration, + } = useMeasurementManager({ + currentFile, + effectiveFile, + rulerOverlayRef, + }); // Register workbench bar buttons for the viewer - useViewerWorkbenchBarButtons(isRulerActive, setIsRulerActive); + useViewerWorkbenchBarButtons( + isRulerActive, + setIsRulerActive, + customScale, + handleSetCustomScale, + isScaleCalibrationActive, + startScaleCalibration, + cancelScaleCalibration, + ); // Auto-fetch form fields when a PDF is loaded in the viewer. // In normal viewer mode, this uses PDFium WASM (frontend-only). @@ -1178,10 +1095,6 @@ const EmbedPdfViewerContent = ({ const formFillProviderRef = useRef(isFormFillToolActive); // Generate a unique identifier for the current file to detect file changes - const currentFileId = React.useMemo(() => { - return getFormFillFileId(currentFile); - }, [currentFile]); - useEffect(() => { const fileChanged = currentFileId !== formFillFileIdRef.current; const providerChanged = @@ -1358,11 +1271,22 @@ const EmbedPdfViewerContent = ({ signatureConfig={signatureConfig} /> + )} diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index 547fba12e7..91c0dcf6a8 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -7,7 +7,7 @@ import React, { } from "react"; import { createPluginRegistration } from "@embedpdf/core"; import type { PluginRegistry } from "@embedpdf/core"; -import { EmbedPDF } from "@embedpdf/core/react"; +import { EmbedPDF, useDocumentState } from "@embedpdf/core/react"; import { usePdfiumEngine } from "@embedpdf/engines/react"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { useAppConfig } from "@app/contexts/AppConfigContext"; @@ -147,6 +147,59 @@ interface LocalEmbedPDFProps { signatureOverlayApiRef?: React.RefObject; } +interface ViewerPageContainerProps { + documentId: string; + pageIndex: number; + width: number; + height: number; + children: React.ReactNode; +} + +function normalizePageRotation(rotation: number | null | undefined): number { + const value = + typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0; + return ((Math.round(value) % 4) + 4) % 4; +} + +function ViewerPageContainer({ + documentId, + pageIndex, + width, + height, + children, +}: ViewerPageContainerProps) { + const documentState = useDocumentState(documentId); + const pageRotation = normalizePageRotation( + documentState?.document?.pages?.[pageIndex]?.rotation, + ); + + return ( +
e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onDragOver={(e) => e.preventDefault()} + > + {children} +
+ ); +} + export function LocalEmbedPDF({ file, url, @@ -1023,25 +1076,11 @@ export function LocalEmbedPDF({ documentId={documentId} pageIndex={pageIndex} > -
e.preventDefault()} - onDrop={(e) => e.preventDefault()} - onDragOver={(e) => e.preventDefault()} +
)} -
+
); diff --git a/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx b/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx index b33b8a6819..a713aac519 100644 --- a/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/RotateAPIBridge.tsx @@ -21,7 +21,8 @@ export function RotateAPIBridge() { function RotateAPIBridgeInner({ documentId }: { documentId: string }) { const { provides: rotate, rotation } = useRotate(documentId); - const { registerBridge } = useViewer(); + const { registerBridge, triggerImmediateRotationUpdate } = useViewer(); + const isRotateAvailable = rotate !== null; // Keep rotate ref updated to avoid re-running effect when object reference changes const rotateRef = useRef(rotate); @@ -29,6 +30,18 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) { rotateRef.current = rotate; }, [rotate]); + // Use the plugin event directly so overlays can update in the same turn as + // the page rotation, instead of waiting for this bridge to re-render. + useEffect(() => { + const currentRotate = rotateRef.current; + if (!currentRotate) return; + + triggerImmediateRotationUpdate(currentRotate.getRotation()); + return currentRotate.onRotateChange((nextRotation) => { + triggerImmediateRotationUpdate(nextRotation); + }); + }, [documentId, isRotateAvailable, triggerImmediateRotationUpdate]); + useEffect(() => { const currentRotate = rotateRef.current; if (currentRotate) { @@ -52,7 +65,7 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) { return () => { registerBridge("rotation", null); }; - }, [rotation, registerBridge]); + }, [rotation, isRotateAvailable, registerBridge]); return null; } diff --git a/frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx b/frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx new file mode 100644 index 0000000000..01f147ba52 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RulerMeasurementLayer.tsx @@ -0,0 +1,950 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import type { MeasureScale, Measurement } from "@app/utils/measurementTypes"; +import { + POINT_TO_UNIT, + convertUnit, + generateScaleLabel, + isImperialUnit, +} from "@app/utils/measurementUtils"; + +export interface RulerPoint { + x: number; + y: number; +} + +export interface RulerRenderedMeasurement { + measurement: Measurement; + startS: RulerPoint; + endS: RulerPoint; + distPts: number; + measureScale: MeasureScale | null; +} + +export type RulerLabelVisibilityMode = "hideSmall" | "showAll" | "hideAll"; + +interface RulerMeasurementLayerProps { + measurements: RulerRenderedMeasurement[]; + zoom: number; + selectedId: string | null; + hoveredId: string | null; + labelVisibilityMode: RulerLabelVisibilityMode; + isInteractionPassthroughActive: boolean; + liveLine?: { + startS: RulerPoint; + endS: RulerPoint; + measureScale?: MeasureScale | null; + } | null; + firstPoint?: RulerPoint | null; + cursor?: RulerPoint | null; + pageContentRef?: React.Ref; + onSelect: (id: string | null) => void; + onDelete: (id: string) => void; + onHoverChange: (id: string | null) => void; + onClearAll: () => void; + onCycleLabelVisibilityMode: () => void; +} + +interface LabelBox { + left: number; + top: number; + right: number; + bottom: number; +} + +interface MeasurementLineLabels { + scaled: string; + physical: string; +} + +export const RULER_DOT_RADIUS = 5; + +const TICK = 10; +const LH = 26; // label height (normal, 1 line) +const LH2 = 44; // label height (hovered, no scale, 2 lines) +const LH3 = 62; // label height (hovered, with scale, 3 lines) +const LP = 10; // label horizontal padding +const DEL_R = 8; +const IDLE_LH = 20; +const IDLE_LP = 6; +const IDLE_LABEL_MIN_WIDTH = 42; +const IDLE_LABEL_MIN_LINE_LENGTH = 88; +const IDLE_LABEL_COLLISION_GAP = 6; +const LABEL_LINE_GAP = 8; +const DELETE_LINE_GAP = 10; +const DELETE_LABEL_GAP = 8; +const LINE_HIT_WIDTH = 18; +const LABEL_MODE_BUTTON_WIDTH = 124; + +function dist(a: RulerPoint, b: RulerPoint): number { + return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); +} + +function midpoint(a: RulerPoint, b: RulerPoint): RulerPoint { + return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; +} + +function perpUnit(a: RulerPoint, b: RulerPoint): { nx: number; ny: number } { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + return { nx: -dy / len, ny: dx / len }; +} + +function angleDeg(a: RulerPoint, b: RulerPoint): number { + return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI); +} + +function formatDist(pts: number): string { + const mm = (pts / 72) * 25.4; + if (mm < 100) return `${mm.toFixed(1)} mm`; + if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`; + return `${(mm / 1000).toFixed(2)} m`; +} + +function formatInches(pts: number): string { + const inches = pts / 72; + if (inches < 12) return `${inches.toFixed(2)} in`; + return `${(inches / 12).toFixed(2)} ft`; +} + +function getDecimalPlaces(value: number): number { + const absVal = Math.abs(value); + + const decimalRanges = [ + { threshold: 1000000, decimals: 0 }, + { threshold: 1000, decimals: 2 }, + { threshold: 1, decimals: 3 }, + { threshold: 0.1, decimals: 3 }, + { threshold: 0.01, decimals: 4 }, + { threshold: 0.001, decimals: 5 }, + ]; + + return decimalRanges.find((r) => absVal >= r.threshold)?.decimals ?? 6; +} + +function formatScaled(pts: number, scale: MeasureScale): string { + const val = pts * scale.factor; + if (val === 0) return `0 ${scale.unit}`; + + const decimals = getDecimalPlaces(val); + + return `${val.toFixed(decimals)} ${scale.unit}`; +} + +function scaledCross(pts: number, scale: MeasureScale): string | null { + const unit = scale.unit.toLowerCase().trim(); + + if (!Object.hasOwn(POINT_TO_UNIT, unit)) return null; + + const valueInUnit = pts * scale.factor; + + if (isImperialUnit(scale.unit)) { + const meters = convertUnit(valueInUnit, scale.unit, "m"); + if (meters === null) return null; + const decimals = getDecimalPlaces(meters); + return `${meters.toFixed(decimals)} m`; + } else { + const feet = convertUnit(valueInUnit, scale.unit, "ft"); + if (feet === null) return null; + const decimals = getDecimalPlaces(feet); + return `${feet.toFixed(decimals)} ft`; + } +} + +function getZoomScale(zoom: number): number { + return Math.max(0.6, Math.min(1.0, zoom / 1.5)); +} + +function getMeasurementLabel( + distPts: number, + measureScale?: MeasureScale | null, +): string { + return measureScale + ? formatScaled(distPts, measureScale) + : formatDist(distPts); +} + +function preferredPerpUnit( + a: RulerPoint, + b: RulerPoint, +): { nx: number; ny: number } { + const normal = perpUnit(a, b); + if (normal.ny > 0 || (Math.abs(normal.ny) < 0.001 && normal.nx < 0)) { + return { nx: -normal.nx, ny: -normal.ny }; + } + return normal; +} + +function getLabelCenter( + a: RulerPoint, + b: RulerPoint, + width: number, + height: number, +): RulerPoint { + const mid = midpoint(a, b); + const { nx, ny } = preferredPerpUnit(a, b); + const projectedHalfSize = + (Math.abs(nx) * width) / 2 + (Math.abs(ny) * height) / 2; + const offset = projectedHalfSize + RULER_DOT_RADIUS + LABEL_LINE_GAP; + return { + x: mid.x + nx * offset, + y: mid.y + ny * offset, + }; +} + +function getIdleLabelDimensions(label: string, zoom: number) { + return { + width: Math.max(label.length * 7 + IDLE_LP * 2, IDLE_LABEL_MIN_WIDTH), + height: Math.max(18, Math.round(IDLE_LH * getZoomScale(zoom))), + }; +} + +function getIdleLabelBox( + startS: RulerPoint, + endS: RulerPoint, + label: string, + zoom: number, +): LabelBox { + const { width, height } = getIdleLabelDimensions(label, zoom); + const center = getLabelCenter(startS, endS, width, height); + return { + left: center.x - width / 2, + top: center.y - height / 2, + right: center.x + width / 2, + bottom: center.y + height / 2, + }; +} + +function getBoxFromCenter( + center: RulerPoint, + width: number, + height: number, +): LabelBox { + return { + left: center.x - width / 2, + top: center.y - height / 2, + right: center.x + width / 2, + bottom: center.y + height / 2, + }; +} + +function boxesOverlap(a: LabelBox, b: LabelBox, gap: number): boolean { + return !( + a.right + gap < b.left || + a.left - gap > b.right || + a.bottom + gap < b.top || + a.top - gap > b.bottom + ); +} + +function circleOverlapsBox( + center: RulerPoint, + radius: number, + box: LabelBox, + gap: number, +): boolean { + return ( + center.x + radius + gap >= box.left && + center.x - radius - gap <= box.right && + center.y + radius + gap >= box.top && + center.y - radius - gap <= box.bottom + ); +} + +function lineUnit(a: RulerPoint, b: RulerPoint): { ux: number; uy: number } { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + return { ux: dx / len, uy: dy / len }; +} + +function getDeleteCenter( + startS: RulerPoint, + endS: RulerPoint, + labelCenter: RulerPoint, + labelWidth: number, + labelHeight: number, +): RulerPoint { + const { nx, ny } = preferredPerpUnit(startS, endS); + const endpointCenter = { + x: endS.x + nx * (RULER_DOT_RADIUS + DEL_R + DELETE_LINE_GAP), + y: endS.y + ny * (RULER_DOT_RADIUS + DEL_R + DELETE_LINE_GAP), + }; + const labelBox = getBoxFromCenter(labelCenter, labelWidth, labelHeight); + + if (!circleOverlapsBox(endpointCenter, DEL_R, labelBox, DELETE_LABEL_GAP)) { + return endpointCenter; + } + + const { ux, uy } = lineUnit(startS, endS); + const projectedLabelRadius = + (Math.abs(ux) * labelWidth) / 2 + (Math.abs(uy) * labelHeight) / 2; + + return { + x: labelCenter.x + ux * (projectedLabelRadius + DEL_R + DELETE_LABEL_GAP), + y: labelCenter.y + uy * (projectedLabelRadius + DEL_R + DELETE_LABEL_GAP), + }; +} + +function getVisibleIdleLabelIds( + renderedMeasurements: RulerRenderedMeasurement[], + zoom: number, +): Set { + const visibleIds = new Set(); + const occupiedBoxes: LabelBox[] = []; + + renderedMeasurements + .map((item, index) => ({ + item, + index, + lineLength: dist(item.startS, item.endS), + label: getMeasurementLabel(item.distPts, item.measureScale), + })) + .sort((a, b) => b.lineLength - a.lineLength || a.index - b.index) + .forEach(({ item, lineLength, label }) => { + const box = getIdleLabelBox(item.startS, item.endS, label, zoom); + const labelWidth = box.right - box.left; + const hasEnoughRoom = + lineLength >= Math.max(IDLE_LABEL_MIN_LINE_LENGTH, labelWidth + 24); + const collides = occupiedBoxes.some((occupiedBox) => + boxesOverlap(box, occupiedBox, IDLE_LABEL_COLLISION_GAP), + ); + + if (hasEnoughRoom && !collides) { + visibleIds.add(item.measurement.id); + occupiedBoxes.push(box); + } + }); + + return visibleIds; +} + +interface MeasurementLineProps { + id: string; + startS: RulerPoint; + endS: RulerPoint; + distPts: number; + isSelected: boolean; + isHovered: boolean; + onSelect: (id: string | null) => void; + onDelete: (id: string) => void; + onHoverChange: (id: string | null) => void; + measureScale?: MeasureScale | null; + zoom: number; + showIdleLabel: boolean; + expandLabelOnHover: boolean; + isInteractionPassthroughActive: boolean; + labels: MeasurementLineLabels; +} + +function MeasurementLine({ + id, + startS, + endS, + distPts, + isSelected, + isHovered, + onSelect, + onDelete, + onHoverChange, + measureScale, + zoom, + showIdleLabel, + expandLabelOnHover, + isInteractionPassthroughActive, + labels, +}: MeasurementLineProps) { + const { nx, ny } = preferredPerpUnit(startS, endS); + const ang = angleDeg(startS, endS); + const angLabel = `∠ ${ang.toFixed(1)}°`; + + const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit); + const distLabel = getMeasurementLabel(distPts, measureScale); + + const hoverLine1 = measureScale + ? (() => { + const primary = formatScaled(distPts, measureScale); + const cross = scaledCross(distPts, measureScale); + return cross + ? `${labels.scaled}: ${primary} / ${cross}` + : `${labels.scaled}: ${primary}`; + })() + : `${formatDist(distPts)} / ${formatInches(distPts)}`; + + const hoverLine2 = measureScale + ? imperialFirst + ? `${labels.physical}: ${formatInches(distPts)} / ${formatDist(distPts)}` + : `${labels.physical}: ${formatDist(distPts)} / ${formatInches(distPts)}` + : null; + + const scaleLabel = measureScale + ? generateScaleLabel(measureScale.ratio, measureScale.unit) + : null; + const contextLabel = scaleLabel ? `${scaleLabel} ${angLabel}` : angLabel; + + const zoomScale = getZoomScale(zoom); + const scaledLH = Math.round(LH * zoomScale); + const scaledLH2 = Math.round(LH2 * zoomScale); + const scaledLH3 = Math.round(LH3 * zoomScale); + const idleDimensions = getIdleLabelDimensions(distLabel, zoom); + + const maxHoverLh = measureScale ? scaledLH3 : scaledLH2; + const isHoveredLabelExpanded = isHovered && expandLabelOnHover; + const isInspecting = isSelected || isHoveredLabelExpanded; + const isCompactIdle = !isInspecting; + const showLabel = isSelected || isHovered || showIdleLabel; + const showDelete = isSelected || isHovered; + const lh = isSelected + ? maxHoverLh + : isHoveredLabelExpanded + ? scaledLH + : idleDimensions.height; + + const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80); + const lwHover = Math.max( + hoverLine1.length * 8 + LP * 2, + (hoverLine2?.length ?? 0) * 8 + LP * 2, + contextLabel.length * 8 + LP * 2, + 80, + ); + const lw = isSelected + ? lwHover + : isHoveredLabelExpanded + ? lwNormal + : idleDimensions.width; + const sw = isSelected ? 3 : 2; + + const labelCenter = getLabelCenter(startS, endS, lw, lh); + const deleteCenter = getDeleteCenter(startS, endS, labelCenter, lw, lh); + const mono = "'Roboto Mono','Consolas',monospace"; + + return ( + { + if (!isInteractionPassthroughActive) { + onHoverChange(id); + } + }} + onPointerLeave={() => onHoverChange(null)} + onClick={(e) => { + if (isInteractionPassthroughActive) { + return; + } + + e.stopPropagation(); + onSelect(isSelected ? null : id); + }} + style={{ + pointerEvents: isInteractionPassthroughActive ? "none" : "all", + cursor: isInteractionPassthroughActive ? "crosshair" : "pointer", + }} + > + + + + + + + + + {showLabel && ( + + + + + + {isSelected && measureScale ? ( + <> + + {hoverLine1} + + + {hoverLine2} + + + {contextLabel} + + + ) : isSelected ? ( + <> + + {hoverLine1} + + + {contextLabel} + + + ) : ( + + {distLabel} + + )} + + )} + + {showDelete && ( + <> + + { + e.stopPropagation(); + onDelete(id); + }} + > + + + × + + + + )} + + ); +} + +interface LiveLineProps { + startS: RulerPoint; + endS: RulerPoint; + zoom: number; + measureScale?: MeasureScale | null; +} + +function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) { + const d = dist(startS, endS) / zoom; + const { nx, ny } = preferredPerpUnit(startS, endS); + const ang = angleDeg(startS, endS); + const distLabel = measureScale + ? formatScaled(d, measureScale) + : formatDist(d); + const lw = Math.max(distLabel.length * 8 + LP * 2, 80); + const lh = Math.round(LH2 * getZoomScale(zoom)); + const labelCenter = getLabelCenter(startS, endS, lw, lh); + + return ( + + + + {d > 4 && ( + + + + {distLabel} + + + {`∠ ${ang.toFixed(1)}°`} + + + )} + + ); +} + +export function RulerMeasurementLayer({ + measurements, + zoom, + selectedId, + hoveredId, + labelVisibilityMode, + isInteractionPassthroughActive, + liveLine, + firstPoint, + cursor, + pageContentRef, + onSelect, + onDelete, + onHoverChange, + onClearAll, + onCycleLabelVisibilityMode, +}: RulerMeasurementLayerProps) { + const { t } = useTranslation(); + const measurementLineLabels = React.useMemo( + () => ({ + scaled: t("ruler.scaled", "Scaled"), + physical: t("ruler.physicalValues", "Physical"), + }), + [t], + ); + const visibleIdleLabelIds = React.useMemo( + () => getVisibleIdleLabelIds(measurements, zoom), + [measurements, zoom], + ); + const orderedMeasurements = React.useMemo(() => { + const getRank = (item: RulerRenderedMeasurement) => { + if (item.measurement.id === hoveredId) { + return 2; + } + + if (item.measurement.id === selectedId) { + return 1; + } + + return 0; + }; + + return [...measurements].sort((a, b) => getRank(a) - getRank(b)); + }, [hoveredId, measurements, selectedId]); + + const getShouldShowIdleLabel = (measurementId: string) => { + if (labelVisibilityMode === "showAll") { + return true; + } + + if (labelVisibilityMode === "hideAll") { + return false; + } + + return visibleIdleLabelIds.has(measurementId); + }; + + const labelModeButtonLabel = + labelVisibilityMode === "hideSmall" + ? t("ruler.showAllLabels", "Show all labels") + : labelVisibilityMode === "showAll" + ? t("ruler.hideAllLabels", "Hide all labels") + : t("ruler.hideSmallLabels", "Hide small labels"); + + return ( + <> + + {orderedMeasurements.map((item) => { + const { measurement, startS, endS, distPts, measureScale } = item; + return ( + + ); + })} + + {firstPoint && ( + + )} + + + {liveLine && ( + + )} + + {cursor && ( + + + + + + )} + + {measurements.length > 0 && ( + <> + { + e.stopPropagation(); + onClearAll(); + }} + > + + + {t("ruler.clearAll", "Clear all")} + + + { + e.stopPropagation(); + onCycleLabelVisibilityMode(); + }} + > + + + {labelModeButtonLabel} + + + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/RulerOverlay.tsx b/frontend/editor/src/core/components/viewer/RulerOverlay.tsx index 7fa7debeb7..9f431fb628 100644 --- a/frontend/editor/src/core/components/viewer/RulerOverlay.tsx +++ b/frontend/editor/src/core/components/viewer/RulerOverlay.tsx @@ -1,5 +1,31 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + restorePosition, + transformPosition, + transformSize, + type Rotation, + type Size, +} from "@embedpdf/models"; import { useViewer } from "@app/contexts/ViewerContext"; +import type { + MeasureScale, + Measurement, + PageMeasureScales, + PagePoint, +} from "@app/utils/measurementTypes"; +import { validateMeasurement } from "@app/utils/measurementUtils"; +import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog"; +import { + RulerMeasurementLayer, + type RulerLabelVisibilityMode, + type RulerRenderedMeasurement, +} from "@app/components/viewer/RulerMeasurementLayer"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -8,35 +34,32 @@ interface Point { y: number; } -/** - * A point anchored to a specific PDF page in PDF-unit space. - * x and y are in PDF points (1/72 inch) relative to the page's top-left corner. - * - * This is the only truly zoom-invariant representation. Screen positions are - * recovered at render time via getBoundingClientRect on the page element, so - * scroll, zoom, and fixed page margins are all handled by the browser — we never - * have to track them ourselves. - */ -interface PagePoint { - pageIndex: number; - x: number; - y: number; -} +let rulerMeasurementIdCounter = 0; -interface Measurement { - id: string; - start: PagePoint; - end: PagePoint; +function createRulerMeasurementId(): string { + rulerMeasurementIdCounter += 1; + return `ruler-${Date.now().toString(36)}-${rulerMeasurementIdCounter.toString(36)}`; } export interface RulerOverlayHandle { - clearAll: () => void; + clearAll: (silent?: boolean) => void; + getMeasurements: () => Measurement[]; + setMeasurements: (measurements: Measurement[]) => void; + /** Restore measurements without triggering notification */ + restoreMeasurements: (measurements: Measurement[]) => void; + /** Register a callback to be notified when measurements change from user actions */ + onMeasurementsChange: ( + callback: (measurements: Measurement[]) => void, + ) => () => void; } interface RulerOverlayProps { containerRef: React.RefObject; isActive: boolean; pageMeasureScales?: PageMeasureScales | null; + customScale?: MeasureScale | null; + isCalibrationActive?: boolean; + onCalibrationMeasure?: (measurement: ScaleCalibrationMeasurement) => void; } // ─── Math ───────────────────────────────────────────────────────────────────── @@ -45,69 +68,111 @@ function dist(a: Point, b: Point): number { return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); } -function midpoint(a: Point, b: Point): Point { - return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; +function normalizeRotation(rotation: number | null | undefined): Rotation { + const value = + typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0; + return (((Math.round(value) % 4) + 4) % 4) as Rotation; } -function perpUnit(a: Point, b: Point): { nx: number; ny: number } { - const dx = b.x - a.x; - const dy = b.y - a.y; - const len = Math.sqrt(dx * dx + dy * dy) || 1; - return { nx: -dy / len, ny: dx / len }; +function getPageRotation(pageEl: HTMLElement): Rotation { + return normalizeRotation(Number(pageEl.dataset.pageRotation)); } -/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */ -function angleDeg(a: Point, b: Point): number { - return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI); +function getEffectivePageRotation( + pageEl: HTMLElement, + documentRotation: Rotation, +): Rotation { + return normalizeRotation(getPageRotation(pageEl) + documentRotation); } -function formatDist(pts: number): string { - const mm = (pts / 72) * 25.4; - if (mm < 100) return `${mm.toFixed(1)} mm`; - if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`; - return `${(mm / 1000).toFixed(2)} m`; +function getPageNaturalSize( + pageEl: HTMLElement, + pageRect: DOMRect, + zoom: number, + rotation: Rotation, +): Size { + const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + const dataWidth = Number(pageEl.dataset.pageWidth); + const dataHeight = Number(pageEl.dataset.pageHeight); + + if ( + Number.isFinite(dataWidth) && + dataWidth > 0 && + Number.isFinite(dataHeight) && + dataHeight > 0 + ) { + return { + width: dataWidth / safeZoom, + height: dataHeight / safeZoom, + }; + } + + const visualWidth = pageRect.width / safeZoom; + const visualHeight = pageRect.height / safeZoom; + return rotation % 2 === 0 + ? { width: visualWidth, height: visualHeight } + : { width: visualHeight, height: visualWidth }; } -function formatInches(pts: number): string { - const inches = pts / 72; - if (inches < 12) return `${inches.toFixed(2)} in`; - return `${(inches / 12).toFixed(2)} ft`; +function clampToPage(point: Point, pageSize: Size): Point { + return { + x: Math.max(0, Math.min(pageSize.width, point.x)), + y: Math.max(0, Math.min(pageSize.height, point.y)), + }; } -export interface MeasureScale { - /** real_world_value = pdf_points * factor */ - factor: number; - /** e.g. "ft", "m" */ - unit: string; - /** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */ - ratioLabel: string; +function clientPointToPagePoint( + pageEl: HTMLElement, + clientX: number, + clientY: number, + zoom: number, + rotation: Rotation, +): Point { + const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + const pageRect = pageEl.getBoundingClientRect(); + const pageSize = getPageNaturalSize(pageEl, pageRect, safeZoom, rotation); + const rotatedDisplaySize = transformSize(pageSize, rotation, safeZoom); + const displayPoint = { + x: clientX - pageRect.left, + y: clientY - pageRect.top, + }; + + return clampToPage( + restorePosition(rotatedDisplaySize, displayPoint, rotation, safeZoom), + pageSize, + ); } -export interface ViewportScale { - /** BBox in PDF user space (bottom-left origin). null = entire page. */ - bbox: [number, number, number, number] | null; - scale: MeasureScale; +function pagePointToDisplayPoint( + pageEl: HTMLElement, + pageRect: DOMRect, + point: Point, + zoom: number, + rotation: Rotation, +): Point { + const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + const pageSize = getPageNaturalSize(pageEl, pageRect, safeZoom, rotation); + return transformPosition(pageSize, point, rotation, safeZoom); } -export interface PageScaleInfo { - viewports: ViewportScale[]; - /** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */ - pageHeight: number; -} - -export type PageMeasureScales = Map; - /** * Given the start/end PagePoints of a measurement, find the scale from the - * viewport whose BBox contains the midpoint. Falls back to the first viewport - * if none contains it (handles whole-page viewports with bbox=null). + * custom scale, then the viewport whose BBox contains the midpoint, then the + * first whole-page viewport with bbox=null. */ function pickScale( start: PagePoint, end: PagePoint, - pageMeasureScales: PageMeasureScales, + pageMeasureScales: PageMeasureScales | null | undefined, + customScale?: MeasureScale | null, ): MeasureScale | null { + // Cross-page measurements are meaningless — reject regardless of scale source if (start.pageIndex !== end.pageIndex) return null; + + // Priority 1: Use custom scale if provided + if (customScale) return customScale; + + if (!pageMeasureScales) return null; const info = pageMeasureScales.get(start.pageIndex); if (!info?.viewports.length) return null; @@ -116,8 +181,14 @@ function pickScale( // Flip y: screen y=0 is page top; PDF user space y=0 is page bottom const my = info.pageHeight - (start.y + end.y) / 2; + let fallbackScale: MeasureScale | null = null; + for (const { bbox, scale } of info.viewports) { - if (!bbox) return scale; // whole-page viewport + if (!bbox) { + fallbackScale ??= scale; + continue; + } + const [x0, y0, x1, y1] = bbox; if ( mx >= Math.min(x0, x1) && @@ -128,58 +199,7 @@ function pickScale( return scale; } } - return null; -} - -function formatScaled(pts: number, scale: MeasureScale): string { - const val = pts * scale.factor; - if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`; - if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`; - if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`; - return `${val.toFixed(3)} ${scale.unit}`; -} - -// Conversion factors to metres for known units -const TO_METRES: Record = { - m: 1, - cm: 0.01, - mm: 0.001, - km: 1000, - ft: 0.3048, - in: 0.0254, - yd: 0.9144, - mi: 1609.344, -}; - -function isImperialUnit(unit: string): boolean { - return ["ft", "in", "yd", "mi"].includes(unit.toLowerCase().trim()); -} - -function formatMetricFromMetres(m: number): string { - if (m >= 1000) return `${(m / 1000).toFixed(2)} km`; - if (m >= 1) return `${m.toFixed(1)} m`; - if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`; - return `${(m * 1000).toFixed(1)} mm`; -} - -function formatImperialFromFeet(ft: number): string { - if (ft >= 1) return `${ft.toFixed(2)} ft`; - return `${(ft * 12).toFixed(2)} in`; -} - -/** - * Returns the scaled real-world value in the *other* unit system, or null if - * the unit is not a recognised metric/imperial unit. - * e.g. 72 pts, scale {factor:0.138889, unit:"ft"} → "3.048 m" - * 72 pts, scale {factor:0.352778, unit:"m"} → "1.157 ft" (approx) - */ -function scaledCross(pts: number, scale: MeasureScale): string | null { - const toM = TO_METRES[scale.unit.toLowerCase().trim()]; - if (!toM) return null; - const metres = pts * scale.factor * toM; - return isImperialUnit(scale.unit) - ? formatMetricFromMetres(metres) - : formatImperialFromFeet(metres / 0.3048); + return fallbackScale; } // ─── DOM helpers ────────────────────────────────────────────────────────────── @@ -202,12 +222,36 @@ function findScrollEl(root: HTMLElement): HTMLElement | null { return null; } -function isOverPage(e: MouseEvent): boolean { - return !!(e.target as Element).closest?.("[data-page-index]"); +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + return ( + target.isContentEditable || + target.closest("input, textarea, select") !== null + ); +} + +function findPageAtClientPoint( + container: HTMLElement, + clientX: number, + clientY: number, +): HTMLElement | null { + const elementsAtPoint = document.elementsFromPoint(clientX, clientY); + + for (const element of elementsAtPoint) { + const pageEl = element.closest?.("[data-page-index]"); + if (pageEl instanceof HTMLElement && container.contains(pageEl)) { + return pageEl; + } + } + + return null; } /** - * Find the nearest point on any page boundary and return it as both + * Find the nearest point on the starting page boundary and return it as both * an SVG screen coordinate and a PagePoint (page-relative PDF units). * Used to clamp the live line when the cursor drifts off the page. */ @@ -215,421 +259,43 @@ function nearestPageDocPt( cursor: Point, container: HTMLElement, zoom: number, + documentRotation: Rotation, + pageIndex: number, ): { screenPt: Point; docPt: PagePoint } | null { - const pages = container.querySelectorAll("[data-page-index]"); - if (!pages.length) return null; + const pageEl = container.querySelector( + `[data-page-index="${pageIndex}"]`, + ) as HTMLElement | null; + if (!pageEl) return null; const cr = container.getBoundingClientRect(); - let bestDist = Infinity; - let best: { screenPt: Point; docPt: PagePoint } | null = null; + const r = pageEl.getBoundingClientRect(); + const effectiveRotation = getEffectivePageRotation(pageEl, documentRotation); - pages.forEach((pageNode) => { - const pageEl = pageNode as HTMLElement; - const r = pageEl.getBoundingClientRect(); - const pageIndex = parseInt(pageEl.dataset.pageIndex ?? "0", 10); + // Page bounds in SVG (container-relative) space + const left = r.left - cr.left; + const top = r.top - cr.top; + const right = r.right - cr.left; + const bottom = r.bottom - cr.top; - // Page bounds in SVG (container-relative) space - const left = r.left - cr.left; - const top = r.top - cr.top; - const right = r.right - cr.left; - const bottom = r.bottom - cr.top; - - // Nearest point on this rect to the cursor (SVG space) - const cx = Math.max(left, Math.min(right, cursor.x)); - const cy = Math.max(top, Math.min(bottom, cursor.y)); - const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2); - - if (d < bestDist) { - bestDist = d; - // Convert SVG-space point (cx, cy) → page-relative viewport → PDF points: - // viewport position of cx = cr.left + cx - // page-relative position = (cr.left + cx) - r.left - // PDF units = page-relative / zoom - best = { - screenPt: { x: cx, y: cy }, - docPt: { - pageIndex, - x: (cr.left + cx - r.left) / zoom, - y: (cr.top + cy - r.top) / zoom, - }, - }; - } - }); - - return best; -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -const TICK = 10; -const DOT_R = 5; -const LH = 26; // label height (normal — 1 line) -const LH2 = 44; // label height (hovered, no scale — 2 lines) -const LH3 = 62; // label height (hovered, with scale — 3 lines) -const LP = 10; // label horizontal padding -const DEL_R = 8; - -interface MeasurementLineProps { - id: string; - startS: Point; - endS: Point; - /** Physical distance in PDF points (= screen pixel distance / zoom). */ - distPts: number; - hovered: boolean; - onDelete: (id: string) => void; - onHover: (id: string | null) => void; - measureScale?: MeasureScale | null; -} - -function MeasurementLine({ - id, - startS, - endS, - distPts, - hovered, - onDelete, - onHover, - measureScale, -}: MeasurementLineProps) { - const mid = midpoint(startS, endS); - const { nx, ny } = perpUnit(startS, endS); - const ang = angleDeg(startS, endS); - const angLabel = `∠ ${ang.toFixed(1)}°`; - - // Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first) - const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit); - - // Idle: scaled primary if scale present, else physical metric - const distLabel = measureScale - ? formatScaled(distPts, measureScale) - : formatDist(distPts); - - // Hover line 1 — both real-world values ordered by PDF unit system: - // imperial PDF: "10.000 ft / 3.048 m" - // metric PDF: "142.5 m / 467.5 ft" - // no scale: "25.4 mm / 1.00 in" (metric first, default) - const hoverLine1 = measureScale - ? (() => { - const primary = formatScaled(distPts, measureScale); - const cross = scaledCross(distPts, measureScale); - return cross ? `${primary} / ${cross}` : primary; - })() - : `${formatDist(distPts)} / ${formatInches(distPts)}`; - - // Hover line 2 — both physical paper values, same order as line 1: - // imperial PDF: "1.00 in / 25.4 mm" - // metric PDF or no scale: "25.4 mm / 1.00 in" - const hoverLine2 = measureScale - ? imperialFirst - ? `${formatInches(distPts)} / ${formatDist(distPts)}` - : `${formatDist(distPts)} / ${formatInches(distPts)}` - : null; - - // Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle - const contextLabel = measureScale?.ratioLabel - ? `${measureScale.ratioLabel} ${angLabel}` - : angLabel; - - const maxHoverLh = measureScale ? LH3 : LH2; - const lh = hovered ? maxHoverLh : LH; - - const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80); - const lwHover = Math.max( - hoverLine1.length * 8 + LP * 2, - (hoverLine2?.length ?? 0) * 8 + LP * 2, - contextLabel.length * 8 + LP * 2, - 80, + // Nearest point on this rect to the cursor (SVG space) + const cx = Math.max(left, Math.min(right, cursor.x)); + const cy = Math.max(top, Math.min(bottom, cursor.y)); + const docPoint = clientPointToPagePoint( + pageEl, + cr.left + cx, + cr.top + cy, + zoom, + effectiveRotation, ); - const lw = hovered ? lwHover : lwNormal; - const sw = hovered ? 3 : 2; - const delX = mid.x + lwHover / 2 + DEL_R + 4; - const delY = mid.y; - - const hitLeft = mid.x - lwHover / 2 - 4; - const hitTop = mid.y - maxHoverLh / 2 - 4; - const hitWidth = delX + DEL_R + 4 - hitLeft; - const hitHeight = maxHoverLh + 8; - - const mono = "'Roboto Mono','Consolas',monospace"; - - return ( - onHover(id)} - onMouseLeave={() => onHover(null)} - style={{ pointerEvents: "all" }} - > - - - - - - - - - - - - {hovered && measureScale ? ( - // 3-line scaled hover - <> - - {hoverLine1} - - - {hoverLine2} - - - {contextLabel} - - - ) : hovered ? ( - // 2-line no-scale hover - <> - - {hoverLine1} - - - {contextLabel} - - - ) : ( - // Idle — single line - - {distLabel} - - )} - - { - e.stopPropagation(); - onDelete(id); - }} - > - - - × - - - - - ); -} - -interface LiveLineProps { - startS: Point; - endS: Point; - zoom: number; - measureScale?: MeasureScale | null; -} - -function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) { - const d = dist(startS, endS) / zoom; // PDF points from screen distance - const mid = midpoint(startS, endS); - const { nx, ny } = perpUnit(startS, endS); - const ang = angleDeg(startS, endS); - const distLabel = measureScale - ? formatScaled(d, measureScale) - : formatDist(d); - const lw = Math.max(distLabel.length * 8 + LP * 2, 80); - - return ( - - - - {d > 4 && ( - - - - {distLabel} - - - {`∠ ${ang.toFixed(1)}°`} - - - )} - - ); + return { + screenPt: { x: cx, y: cy }, + docPt: { + pageIndex, + x: docPoint.x, + y: docPoint.y, + }, + }; } // ─── Main ───────────────────────────────────────────────────────────────────── @@ -637,14 +303,34 @@ function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) { export const RulerOverlay = React.forwardRef< RulerOverlayHandle, RulerOverlayProps ->(({ containerRef, isActive, pageMeasureScales }, ref) => { +>(function RulerOverlayImpl( + { + containerRef, + isActive, + pageMeasureScales, + customScale, + isCalibrationActive = false, + onCalibrationMeasure, + }: RulerOverlayProps, + ref, +) { const [measurements, setMeasurements] = useState([]); const [firstPt, setFirstPt] = useState(null); /** Current cursor in SVG screen-space — for live crosshair and live line rendering. */ const [cursorS, setCursorS] = useState(null); /** Current cursor in page-relative PDF units — for finalising off-page clicks. */ const [cursorDoc, setCursorDoc] = useState(null); + const [selectedId, setSelectedId] = useState(null); const [hoveredId, setHoveredId] = useState(null); + const [labelVisibilityMode, setLabelVisibilityMode] = + useState("hideSmall"); + const [isDrawThroughActive, setIsDrawThroughActive] = useState(false); + + // Callbacks for explicit measurement changes; restores stay silent. + const measurementsListenersRef = useRef< + Set<(measurements: Measurement[]) => void> + >(new Set()); + const measurementsRef = useRef(measurements); /** * Incremented on scroll to trigger re-renders. @@ -655,7 +341,13 @@ export const RulerOverlay = React.forwardRef< const scrollElRef = useRef(null); const scrollCleanupRef = useRef<(() => void) | null>(null); - const idCounter = useRef(0); + const scrollRafRef = useRef(null); + const rulerPageContentRef = useRef(null); + const renderedScrollRef = useRef({ left: 0, top: 0 }); + const isActiveRef = useRef(isActive); + useEffect(() => { + isActiveRef.current = isActive; + }, [isActive]); const firstPtRef = useRef(null); useEffect(() => { @@ -663,10 +355,66 @@ export const RulerOverlay = React.forwardRef< }, [firstPt]); const cursorDocRef = useRef(null); + const wasCalibrationActiveRef = useRef(isCalibrationActive); + const drawThroughActiveRef = useRef(false); + + const setDrawThroughMode = useCallback((isEnabled: boolean) => { + drawThroughActiveRef.current = isEnabled; + setIsDrawThroughActive(isEnabled); + + if (isEnabled) { + setHoveredId(null); + } + }, []); + + const cycleLabelVisibilityMode = useCallback(() => { + setLabelVisibilityMode((currentMode) => { + if (currentMode === "hideSmall") { + return "showAll"; + } + + if (currentMode === "showAll") { + return "hideAll"; + } + + return "hideSmall"; + }); + }, []); + + const notifyMeasurementsChange = useCallback( + (nextMeasurements: Measurement[]) => { + measurementsListenersRef.current.forEach((listener) => + listener(nextMeasurements), + ); + }, + [], + ); + + const replaceMeasurements = useCallback( + (nextMeasurements: Measurement[], notify = false) => { + measurementsRef.current = nextMeasurements; + setMeasurements(nextMeasurements); + if (notify) { + notifyMeasurementsChange(nextMeasurements); + } + }, + [notifyMeasurementsChange], + ); + + const updateMeasurements = useCallback( + ( + updater: (currentMeasurements: Measurement[]) => Measurement[], + notify = false, + ) => { + replaceMeasurements(updater(measurementsRef.current), notify); + }, + [replaceMeasurements], + ); // ── Zoom ────────────────────────────────────────────────────────────────── const viewer = useViewer(); - const { registerImmediateZoomUpdate } = viewer; + const { registerImmediateRotationUpdate, registerImmediateZoomUpdate } = + viewer; const [zoom, setZoom] = useState(() => { try { @@ -681,6 +429,19 @@ export const RulerOverlay = React.forwardRef< zoomRef.current = zoom; }, [zoom]); + const [rotation, setRotation] = useState(() => { + try { + return normalizeRotation(viewer.getRotationState().rotation); + } catch { + return normalizeRotation(0); + } + }); + + const rotationRef = useRef(rotation); + useEffect(() => { + rotationRef.current = rotation; + }, [rotation]); + useEffect(() => { return registerImmediateZoomUpdate((pct) => { const newZoom = pct / 100; @@ -692,16 +453,88 @@ export const RulerOverlay = React.forwardRef< }); }, [registerImmediateZoomUpdate]); + useEffect(() => { + return registerImmediateRotationUpdate((nextRotation) => { + const normalizedRotation = normalizeRotation(nextRotation); + rotationRef.current = normalizedRotation; + setRotation(normalizedRotation); + requestAnimationFrame(() => setScrollVersion((n) => n + 1)); + }); + }, [registerImmediateRotationUpdate]); + + // ── Layout change tracking (menu close, sidebar toggle, etc.) ────────────── + // Monitor PDF container for layout changes and force re-render so measurements + // use updated getBoundingClientRect positions after layout reflow + useEffect(() => { + if (!containerRef.current) return; + + const handleLayoutChange = () => { + // Layout changed - force re-render to recalculate coordinates from getBoundingClientRect + setScrollVersion((n) => n + 1); + }; + + // Use ResizeObserver if available, otherwise fall back to window resize event + if (typeof ResizeObserver !== "undefined") { + const resizeObserver = new ResizeObserver(handleLayoutChange); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + } else { + // Fallback for environments without ResizeObserver (legacy browsers, embedded webviews) + window.addEventListener("resize", handleLayoutChange); + return () => window.removeEventListener("resize", handleLayoutChange); + } + }, [containerRef]); + // ── Scroll tracking ──────────────────────────────────────────────────────── - // We only need re-renders on scroll; getBoundingClientRect gives us accurate - // positions without needing to know the scroll offset ourselves. + // Native scrolling moves the PDF pages before React re-renders this fixed + // overlay. Translate page-anchored SVG content immediately, then let the next + // frame render exact coordinates from getBoundingClientRect. + + useLayoutEffect(() => { + const scrollEl = scrollElRef.current; + if (scrollEl) { + renderedScrollRef.current = { + left: scrollEl.scrollLeft, + top: scrollEl.scrollTop, + }; + } + rulerPageContentRef.current?.removeAttribute("transform"); + }); const attachScrollEl = useCallback((el: HTMLElement) => { scrollCleanupRef.current?.(); scrollElRef.current = el; - const handler = () => setScrollVersion((n) => n + 1); + const handler = () => { + if (!isActiveRef.current && measurementsRef.current.length === 0) { + return; + } + + const dx = renderedScrollRef.current.left - el.scrollLeft; + const dy = renderedScrollRef.current.top - el.scrollTop; + if (dx !== 0 || dy !== 0) { + rulerPageContentRef.current?.setAttribute( + "transform", + `translate(${dx} ${dy})`, + ); + } + + if (scrollRafRef.current !== null) { + return; + } + + scrollRafRef.current = requestAnimationFrame(() => { + scrollRafRef.current = null; + setScrollVersion((n) => n + 1); + }); + }; el.addEventListener("scroll", handler, { passive: true }); - scrollCleanupRef.current = () => el.removeEventListener("scroll", handler); + scrollCleanupRef.current = () => { + el.removeEventListener("scroll", handler); + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + }; }, []); useEffect(() => { @@ -737,22 +570,100 @@ export const RulerOverlay = React.forwardRef< // ── Imperative handle ────────────────────────────────────────────────────── React.useImperativeHandle(ref, () => ({ - clearAll: () => { - setMeasurements([]); + clearAll: (silent = false) => { + firstPtRef.current = null; + cursorDocRef.current = null; + replaceMeasurements([], !silent); setFirstPt(null); setCursorS(null); setCursorDoc(null); + setSelectedId(null); + setHoveredId(null); + }, + getMeasurements: () => measurementsRef.current, + setMeasurements: (newMeasurements: Measurement[]) => { + // Validate all measurements before setting state + const validated = newMeasurements.filter((m) => validateMeasurement(m)); + replaceMeasurements(validated, true); + }, + restoreMeasurements: (newMeasurements: Measurement[]) => { + replaceMeasurements( + newMeasurements.filter((measurement) => + validateMeasurement(measurement), + ), + false, + ); + }, + onMeasurementsChange: (callback: (measurements: Measurement[]) => void) => { + measurementsListenersRef.current.add(callback); + // Return unsubscribe function + return () => { + measurementsListenersRef.current.delete(callback); + }; }, })); // ── Reset when deactivated ───────────────────────────────────────────────── useEffect(() => { if (!isActive) { + firstPtRef.current = null; + cursorDocRef.current = null; + setFirstPt(null); + setCursorS(null); + setCursorDoc(null); + setSelectedId(null); + setHoveredId(null); + setDrawThroughMode(false); + } + }, [isActive, setDrawThroughMode]); + + useEffect(() => { + const wasCalibrationActive = wasCalibrationActiveRef.current; + wasCalibrationActiveRef.current = isCalibrationActive; + + if (wasCalibrationActive !== isCalibrationActive) { + firstPtRef.current = null; + cursorDocRef.current = null; setFirstPt(null); setCursorS(null); setCursorDoc(null); } - }, [isActive]); + }, [isCalibrationActive]); + + useEffect(() => { + if (!isActive) { + return; + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Alt" || isEditableKeyboardTarget(e.target)) { + return; + } + + e.preventDefault(); + setDrawThroughMode(true); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === "Alt") { + setDrawThroughMode(false); + } + }; + + const onBlur = () => { + setDrawThroughMode(false); + }; + + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", onBlur); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + setDrawThroughMode(false); + }; + }, [isActive, setDrawThroughMode]); // ── Mouse events ─────────────────────────────────────────────────────────── useEffect(() => { @@ -766,20 +677,28 @@ export const RulerOverlay = React.forwardRef< /** * Convert a mouse event to a page-relative PagePoint. - * Returns null if the cursor is not directly over a page element. + * Returns null if the cursor is not over a page. */ const toDocPagePt = (e: MouseEvent): PagePoint | null => { - const pageEl = (e.target as Element).closest?.( - "[data-page-index]", - ) as HTMLElement | null; + const pageEl = findPageAtClientPoint(el, e.clientX, e.clientY); if (!pageEl) return null; const pageIndex = parseInt(pageEl.dataset.pageIndex ?? "0", 10); - const r = pageEl.getBoundingClientRect(); const z = zoomRef.current; + const effectiveRotation = getEffectivePageRotation( + pageEl, + rotationRef.current, + ); + const docPoint = clientPointToPagePoint( + pageEl, + e.clientX, + e.clientY, + z, + effectiveRotation, + ); return { pageIndex, - x: (e.clientX - r.left) / z, - y: (e.clientY - r.top) / z, + x: docPoint.x, + y: docPoint.y, }; }; @@ -791,17 +710,23 @@ export const RulerOverlay = React.forwardRef< const onMove = (e: MouseEvent) => { const screenPt = toScreenPt(e); + const docPt = toDocPagePt(e); - if (isOverPage(e)) { + if (docPt) { el.style.cursor = "crosshair"; - const docPt = toDocPagePt(e); setCursorS(screenPt); setCursorDoc(docPt); cursorDocRef.current = docPt; } else if (firstPtRef.current !== null) { // First point placed, cursor wandered off page — clamp to nearest edge el.style.cursor = "crosshair"; - const result = nearestPageDocPt(screenPt, el, zoomRef.current); + const result = nearestPageDocPt( + screenPt, + el, + zoomRef.current, + rotationRef.current, + firstPtRef.current.pageIndex, + ); if (result) { setCursorS(result.screenPt); setCursorDoc(result.docPt); @@ -815,25 +740,67 @@ export const RulerOverlay = React.forwardRef< const onClick = (e: MouseEvent) => { if (e.button !== 0) return; - if ((e.target as Element).closest?.("[data-ruler-interactive]")) return; + const target = e.target as Element; + if (target.closest?.("[data-ruler-control]")) return; - const overPage = isOverPage(e); + const hasMeasurementInProgress = + firstPtRef.current !== null || drawThroughActiveRef.current || e.altKey; + if ( + !hasMeasurementInProgress && + target.closest?.("[data-ruler-interactive]") + ) { + return; + } + + const dp = toDocPagePt(e); + const overPage = dp !== null; if (!overPage && firstPtRef.current === null) return; e.preventDefault(); - const dp = overPage ? toDocPagePt(e) : cursorDocRef.current; - if (!dp) return; + const nextPoint = dp ?? cursorDocRef.current; + if (!nextPoint) return; - setFirstPt((prev) => { - if (!prev) { - firstPtRef.current = dp; - return dp; - } + const prev = firstPtRef.current; + if (!prev) { + firstPtRef.current = nextPoint; + setFirstPt(nextPoint); + setSelectedId(null); + setHoveredId(null); + return; + } + + // CRITICAL: Reject cross-page measurements + // Measurements must have both points on the same page + if (prev.pageIndex !== nextPoint.pageIndex) { + // Reset first point so user can start fresh on same page firstPtRef.current = null; - const id = `ruler-${++idCounter.current}`; - setMeasurements((m) => [...m, { id, start: prev, end: dp }]); - return null; - }); + cursorDocRef.current = null; + setFirstPt(null); + setCursorS(null); + setCursorDoc(null); + return; + } + + firstPtRef.current = null; + setFirstPt(null); + + if (isCalibrationActive) { + const distancePts = dist(prev, nextPoint); + if (distancePts > 0) { + onCalibrationMeasure?.({ + start: prev, + end: nextPoint, + pdfDistancePts: distancePts, + }); + } + return; + } + + const id = createRulerMeasurementId(); + updateMeasurements( + (m) => [...m, { id, start: prev, end: nextPoint }], + true, + ); }; const onLeave = () => { @@ -842,6 +809,8 @@ export const RulerOverlay = React.forwardRef< }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + firstPtRef.current = null; + cursorDocRef.current = null; setFirstPt(null); setCursorS(null); setCursorDoc(null); @@ -859,11 +828,27 @@ export const RulerOverlay = React.forwardRef< document.removeEventListener("keydown", onKey); el.style.cursor = ""; }; - }, [containerRef, isActive]); + }, [ + containerRef, + isActive, + isCalibrationActive, + onCalibrationMeasure, + updateMeasurements, + ]); - const deleteMeasurement = useCallback((id: string) => { - setMeasurements((prev) => prev.filter((m) => m.id !== id)); - }, []); + const deleteMeasurement = useCallback( + (id: string) => { + updateMeasurements((prev) => prev.filter((m) => m.id !== id), true); + // Close expanded label if the deleted measurement was selected + if (selectedId === id) { + setSelectedId(null); + } + if (hoveredId === id) { + setHoveredId(null); + } + }, + [hoveredId, selectedId, updateMeasurements], + ); if (!isActive && measurements.length === 0) return null; @@ -887,13 +872,60 @@ export const RulerOverlay = React.forwardRef< if (!pageEl) return null; const pageRect = pageEl.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); + const effectiveRotation = getEffectivePageRotation(pageEl, rotation); + const displayPoint = pagePointToDisplayPoint( + pageEl, + pageRect, + pt, + zoom, + effectiveRotation, + ); return { - x: pageRect.left - containerRect.left + pt.x * zoom, - y: pageRect.top - containerRect.top + pt.y * zoom, + x: pageRect.left - containerRect.left + displayPoint.x, + y: pageRect.top - containerRect.top + displayPoint.y, }; }; const firstPtS = firstPt ? pagePointToScreen(firstPt) : null; + const renderedMeasurements = measurements.reduce( + (items, measurement) => { + const startS = pagePointToScreen(measurement.start); + const endS = pagePointToScreen(measurement.end); + if (!startS || !endS) { + return items; + } + + const measureScale = pickScale( + measurement.start, + measurement.end, + pageMeasureScales, + customScale, + ); + + items.push({ + measurement, + startS, + endS, + distPts: dist(measurement.start, measurement.end), + measureScale, + }); + return items; + }, + [], + ); + const isMeasurementInteractionPassthroughActive = + firstPt !== null || isDrawThroughActive; + const liveLine = + isActive && firstPtS && cursorS + ? { + startS: firstPtS, + endS: cursorS, + measureScale: + !isCalibrationActive && firstPt && cursorDoc + ? pickScale(firstPt, cursorDoc, pageMeasureScales, customScale) + : null, + } + : null; return ( { + // Close expanded label if clicking on empty SVG area + if (e.target === e.currentTarget) { + setSelectedId(null); + } + }} > @@ -919,112 +957,29 @@ export const RulerOverlay = React.forwardRef< - {/* Completed measurements */} - {measurements.map((m) => { - const startS = pagePointToScreen(m.start); - const endS = pagePointToScreen(m.end); - if (!startS || !endS) return null; - const mScale = pageMeasureScales - ? pickScale(m.start, m.end, pageMeasureScales) - : null; - return ( - - ); - })} - - {/* Live line while drawing */} - {isActive && firstPtS && cursorS && ( - - )} - - {/* First-point anchor dot */} - {isActive && firstPtS && ( - - )} - - {/* Crosshair */} - {isActive && cursorS && ( - - - - - - )} - - {/* Clear all */} - {measurements.length > 0 && ( - { - e.stopPropagation(); - setMeasurements([]); - }} - > - - - Clear all - - - )} + { + replaceMeasurements([], true); + setSelectedId(null); + setHoveredId(null); + }} + onCycleLabelVisibilityMode={cycleLabelVisibilityMode} + /> ); }); diff --git a/frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx b/frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx new file mode 100644 index 0000000000..88b62d7520 --- /dev/null +++ b/frontend/editor/src/core/components/viewer/RulerScaleSettingsButton.tsx @@ -0,0 +1,82 @@ +import { useRef } from "react"; +import { Popover } from "@mantine/core"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { Tooltip, type TooltipProps } from "@app/components/shared/Tooltip"; +import { ScaleSettingsPanel } from "@app/components/viewer/ScaleSettingsPanel"; +import type { MeasureScale } from "@app/utils/measurementTypes"; +import { ActionIcon } from "@app/ui/ActionIcon"; + +interface RulerScaleSettingsButtonProps { + disabled?: boolean; + label: string; + tooltipPosition: NonNullable; + currentScale?: MeasureScale | null; + onApplyScale?: (scale: MeasureScale) => void; + onResetScale?: () => void; + onStartCalibration?: () => void; + onCancelCalibration?: () => void; + isCalibrationActive?: boolean; +} + +export function RulerScaleSettingsButton({ + disabled, + label, + tooltipPosition, + currentScale, + onApplyScale, + onResetScale, + onStartCalibration, + onCancelCalibration, + isCalibrationActive, +}: RulerScaleSettingsButtonProps) { + const scalePopoverRef = useRef(null); + + return ( + + +
+ + + + + +
+
+ + { + onApplyScale?.(scale); + }} + onResetScale={() => { + onResetScale?.(); + }} + onStartCalibration={onStartCalibration} + onCancelCalibration={onCancelCalibration} + isCalibrationActive={isCalibrationActive} + onClose={() => { + scalePopoverRef.current?.click(); + }} + /> + +
+ ); +} diff --git a/frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx b/frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx new file mode 100644 index 0000000000..3b7b54311f --- /dev/null +++ b/frontend/editor/src/core/components/viewer/ScaleCalibrationDialog.tsx @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import type { MeasureScale, PagePoint } from "@app/utils/measurementTypes"; +import { + UNIT_OPTIONS, + formatPaperDistance, + validateRealDistance, + calculateCalibratedScale, + generateScaleLabel, +} from "@app/utils/measurementUtils"; +import { + getLastCalibrationUnit, + setLastCalibrationUnit, +} from "@app/utils/measurementPreferences"; + +// Result of user drawing measurement on PDF (from RulerOverlay) +export interface ScaleCalibrationMeasurement { + start: PagePoint; + end: PagePoint; + pdfDistancePts: number; +} + +interface ScaleCalibrationDialogProps { + opened: boolean; + measurement: ScaleCalibrationMeasurement | null; + defaultUnit: string; + onApplyScale: (scale: MeasureScale) => void; + onClose: () => void; +} + +export function ScaleCalibrationDialog({ + opened, + measurement, + defaultUnit, + onApplyScale, + onClose, +}: ScaleCalibrationDialogProps) { + const { t } = useTranslation(); + + const [realDistance, setRealDistance] = useState(null); + const [unit, setUnit] = useState(() => getLastCalibrationUnit(defaultUnit)); + const [error, setError] = useState(null); + const wasOpenedRef = useRef(opened); + + const previewScale = useMemo(() => { + if (measurement == null || realDistance == null) { + return null; + } + + try { + return calculateCalibratedScale( + measurement.pdfDistancePts, + realDistance, + unit, + ); + } catch { + return null; + } + }, [measurement, realDistance, unit]); + + useEffect(() => { + const isOpening = opened && !wasOpenedRef.current; + wasOpenedRef.current = opened; + + if (isOpening) { + setRealDistance(null); + setUnit(getLastCalibrationUnit(defaultUnit)); + setError(null); + } + }, [opened, defaultUnit]); + + const handleRealDistanceChange = (value: number | string | null) => { + setError(null); + + const validated = validateRealDistance(value); + setRealDistance(validated); + }; + + const handleUnitChange = (newUnit: string | null) => { + if (newUnit == null) return; + + setUnit(newUnit); + setLastCalibrationUnit(newUnit); + setError(null); + }; + + const handleApply = () => { + if (measurement == null || realDistance == null) { + setError( + t( + "scaleSettings.calibrationDistanceRequired", + "Enter a real-world distance greater than zero", + ), + ); + return; + } + + try { + const scale = calculateCalibratedScale( + measurement.pdfDistancePts, + realDistance, + unit, + ); + + setLastCalibrationUnit(unit); + onApplyScale(scale); + } catch (err) { + console.error("[Calibration] Failed to apply:", err); + setError( + t( + "scaleSettings.calibrationInvalid", + "Unable to calculate scale from this measurement", + ), + ); + } + }; + + return ( + + + {/* Show paper distance automatically */} + {measurement && ( + + {t( + "scaleSettings.calibrationPaperDistance", + "Measured page distance: {{distance}}", + { + distance: formatPaperDistance(measurement.pdfDistancePts), + }, + )} + + )} + + {/* Input: real distance + unit selector */} + + + +
+ + + {t( + "scaleSettings.ratioHelp", + "Ratio: 1 page unit = X real-world units", + )} + + + + + + {/* Active Scale Display */} +
+ + {t("scaleSettings.activeScale", "Active Scale")}:{" "} + {currentScale && currentScale.ratio + ? generateScaleLabel(currentScale.ratio, currentScale.unit) + : currentScale && !currentScale.ratio + ? `${currentScale.unit} (custom)` + : t("scaleSettings.noneSet", "No custom scale set")} + +
+ + {/* Calibration Mode */} + + + {/* Reset Button */} + {currentScale && ( + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 14a308e0f3..d5d39c4c0c 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -26,11 +26,19 @@ import StraightenIcon from "@mui/icons-material/Straighten"; import LayersIcon from "@mui/icons-material/Layers"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import StopIcon from "@mui/icons-material/Stop"; +import SettingsIcon from "@mui/icons-material/Settings"; import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud"; +import { RulerScaleSettingsButton } from "@app/components/viewer/RulerScaleSettingsButton"; +import type { MeasureScale } from "@app/utils/measurementTypes"; export function useViewerWorkbenchBarButtons( isRulerActive?: boolean, setIsRulerActive?: (v: boolean) => void, + customScale?: MeasureScale | null, + setCustomScale?: (scale: MeasureScale | null) => void, + isScaleCalibrationActive?: boolean, + startScaleCalibration?: () => void, + cancelScaleCalibration?: () => void, ) { const { t, i18n } = useTranslation(); const viewer = useViewer(); @@ -118,11 +126,36 @@ export function useViewerWorkbenchBarButtons( const annotationsLabel = t("workbenchBar.annotations", "Annotations"); const formFillLabel = t("workbenchBar.formFill", "Fill Form"); const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure"); + const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings"); const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud"); const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed"); const isFormFillActive = (selectedTool as string) === "formFill"; + const handleStartScaleCalibration = useCallback(() => { + startScaleCalibration?.(); + setIsRulerActive?.(true); + if (isPanning) { + viewer.panActions.disablePan(); + setIsPanning(false); + } + }, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]); + + const handleCancelScaleCalibration = useCallback(() => { + cancelScaleCalibration?.(); + }, [cancelScaleCalibration]); + + const handleApplyRulerScale = useCallback( + (scale: MeasureScale) => { + setCustomScale?.(scale); + }, + [setCustomScale], + ); + + const handleResetRulerScale = useCallback(() => { + setCustomScale?.(null); + }, [setCustomScale]); + // Filter languages based on available voices const filteredLanguages = useMemo( () => @@ -234,6 +267,32 @@ export function useViewerWorkbenchBarButtons( } }, }, + // Ruler scale settings button - only visible when ruler is active + ...(isRulerActive + ? [ + { + id: "viewer-ruler-settings", + icon: , + tooltip: rulerSettingsLabel, + ariaLabel: rulerSettingsLabel, + section: "top" as const, + order: 25.5, + render: ({ disabled }: { disabled?: boolean }) => ( + + ), + }, + ] + : []), { id: "viewer-rotate-left", icon: , @@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons( formFillLabel, isFormFillActive, rulerLabel, + rulerSettingsLabel, isRulerActive, setIsRulerActive, + handleStartScaleCalibration, + handleCancelScaleCalibration, + handleApplyRulerScale, + handleResetRulerScale, + customScale, + isScaleCalibrationActive, readAloudLabel, readAloudSpeedLabel, isReadingAloud, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 5e5d08dbb4..78535b41ac 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -176,6 +176,9 @@ export interface ViewerContextType { registerImmediatePanUpdate: ( callback: (isPanning: boolean) => void, ) => () => void; + registerImmediateRotationUpdate: ( + callback: (rotation: number) => void, + ) => () => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: ( @@ -188,6 +191,7 @@ export interface ViewerContextType { isDualPage?: boolean, ) => void; triggerImmediatePanUpdate: (isPanning: boolean) => void; + triggerImmediateRotationUpdate: (rotation: number) => void; // Action handlers - call EmbedPDF APIs directly scrollActions: ScrollActions; @@ -310,6 +314,10 @@ export const ViewerProvider: React.FC = ({ children }) => { register: registerImmediatePanUpdate, trigger: triggerImmediatePanInternal, } = useImmediateNotifier<[boolean]>(); + const { + register: registerImmediateRotationUpdate, + trigger: triggerImmediateRotationInternal, + } = useImmediateNotifier<[number]>(); const triggerImmediateZoomUpdate = useCallback( (percent: number) => { @@ -339,6 +347,13 @@ export const ViewerProvider: React.FC = ({ children }) => { [triggerImmediatePanInternal], ); + const triggerImmediateRotationUpdate = useCallback( + (rotation: number) => { + triggerImmediateRotationInternal(rotation); + }, + [triggerImmediateRotationInternal], + ); + const registerBridge = useCallback( ( type: K, @@ -638,10 +653,12 @@ export const ViewerProvider: React.FC = ({ children }) => { registerImmediateScrollUpdate, registerImmediateSpreadUpdate, registerImmediatePanUpdate, + registerImmediateRotationUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, triggerImmediateSpreadUpdate, triggerImmediatePanUpdate, + triggerImmediateRotationUpdate, // Actions scrollActions, diff --git a/frontend/editor/src/core/hooks/useMeasurementManager.ts b/frontend/editor/src/core/hooks/useMeasurementManager.ts new file mode 100644 index 0000000000..f6f6b9d6b1 --- /dev/null +++ b/frontend/editor/src/core/hooks/useMeasurementManager.ts @@ -0,0 +1,293 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react"; +import type { + Measurement, + MeasureScale, + PageMeasureScales, +} from "@app/utils/measurementTypes"; +import type { RulerOverlayHandle } from "@app/components/viewer/RulerOverlay"; +import { + loadSessionMap, + saveSessionMap, + validateMeasureScale, + validateMeasurement, +} from "@app/utils/measurementUtils"; +import type { StirlingFile } from "@app/types/fileContext"; +import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; +import { extractPageMeasureScales } from "@app/utils/pdfMeasurementExtraction"; +import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog"; + +// ─── Hook: useMeasurementManager ────────────────────────────────────────────── + +interface EffectiveFileLike { + file: Blob | File; + url: string | null; +} + +type ViewerFile = StirlingFile | File | null | undefined; + +interface UseMeasurementManagerProps { + currentFile: ViewerFile; + effectiveFile: EffectiveFileLike | null | undefined; + rulerOverlayRef: RefObject; +} + +interface UseMeasurementManagerReturn { + isRulerActive: boolean; + setIsRulerActive: (v: boolean) => void; + pageMeasureScales: PageMeasureScales | null; + customScale: MeasureScale | null; + handleSetCustomScale: (scale: MeasureScale | null) => void; + isScaleCalibrationActive: boolean; + scaleCalibrationMeasurement: ScaleCalibrationMeasurement | null; + startScaleCalibration: () => void; + cancelScaleCalibration: () => void; + handleScaleCalibrationMeasurement: ( + measurement: ScaleCalibrationMeasurement, + ) => void; + applyScaleCalibration: (scale: MeasureScale) => void; +} + +export function useMeasurementManager({ + currentFile, + effectiveFile, + rulerOverlayRef, +}: UseMeasurementManagerProps): UseMeasurementManagerReturn { + const [isRulerActive, setIsRulerActive] = useState(false); + const [pageMeasureScales, setPageMeasureScales] = + useState(null); + const [customScale, setCustomScale] = useState(null); + const [isScaleCalibrationActive, setIsScaleCalibrationActive] = + useState(false); + const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] = + useState(null); + const [scalesByFileId, setScalesByFileId] = useState< + Map + >(new Map()); + const [measurementsByFileId, setMeasurementsByFileId] = useState< + Map + >(new Map()); + + const restoredFileKeyRef = useRef(null); + + const getStableFileKey = useCallback((file: ViewerFile): string | null => { + if (!file) return null; + if (isStirlingFile(file)) { + return file.fileId; + } + return getFormFillFileId(file); + }, []); + + const currentFileKey = getStableFileKey(currentFile); + + function persistSessionValue( + storageKey: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, + label: string, + ) { + try { + saveSessionMap(storageKey, fileKey, value); + } catch (error) { + console.error(`[Measurement] Failed to persist ${label}:`, error); + } + } + + function readStoredScale(fileKey: string): MeasureScale | null | undefined { + const storedMap = loadSessionMap("stirling_scales"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + return validateMeasureScale(storedValue) ? storedValue : null; + } + + function readStoredMeasurements(fileKey: string): Measurement[] | undefined { + const storedMap = loadSessionMap("stirling_measurements"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + if (!Array.isArray(storedValue)) { + return []; + } + + return storedValue.filter((measurement) => + validateMeasurement(measurement), + ); + } + + function persistScale(fileKey: string, scale: MeasureScale | null) { + persistSessionValue("stirling_scales", fileKey, scale, "scale"); + } + + function persistMeasurements(fileKey: string, value: Measurement[]) { + persistSessionValue( + "stirling_measurements", + fileKey, + value, + "measurements", + ); + } + + const handleSetCustomScale = useCallback( + (scale: MeasureScale | null) => { + const fileKey = currentFileKey; + + if (fileKey) { + setScalesByFileId((prev) => new Map(prev).set(fileKey, scale)); + persistScale(fileKey, scale); + } + + setCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [currentFileKey], + ); + + const handleSetRulerActive = useCallback((active: boolean) => { + setIsRulerActive(active); + if (!active) { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + } + }, []); + + const startScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(true); + setIsRulerActive(true); + }, []); + + const cancelScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, []); + + const handleScaleCalibrationMeasurement = useCallback( + (measurement: ScaleCalibrationMeasurement) => { + setScaleCalibrationMeasurement(measurement); + setIsScaleCalibrationActive(false); + }, + [], + ); + + const applyScaleCalibration = useCallback( + (scale: MeasureScale) => { + handleSetCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [handleSetCustomScale], + ); + + useEffect(() => { + if (!currentFileKey) { + setPageMeasureScales(null); + setCustomScale(null); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + setIsRulerActive(false); + rulerOverlayRef.current?.clearAll(true); + restoredFileKeyRef.current = null; + return; + } + + if (restoredFileKeyRef.current === currentFileKey) { + return; + } + restoredFileKeyRef.current = currentFileKey; + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + + const storedScale = readStoredScale(currentFileKey); + const savedScale = + storedScale === undefined + ? (scalesByFileId.get(currentFileKey) ?? null) + : storedScale; + + setCustomScale(savedScale); + + const storedMeasurements = readStoredMeasurements(currentFileKey); + const savedMeasurements = + storedMeasurements === undefined + ? (measurementsByFileId.get(currentFileKey) ?? []) + : storedMeasurements; + + rulerOverlayRef.current?.clearAll(true); + rulerOverlayRef.current?.restoreMeasurements(savedMeasurements); + }, [currentFileKey, measurementsByFileId, rulerOverlayRef, scalesByFileId]); + + useEffect(() => { + const fileBlob = effectiveFile?.file; + if (!fileBlob || !currentFileKey) { + setPageMeasureScales(null); + return; + } + + setPageMeasureScales(null); + + let cancelled = false; + extractPageMeasureScales(fileBlob) + .then((scales) => { + if (!cancelled) { + setPageMeasureScales(scales); + } + }) + .catch((error) => { + if (!cancelled) { + console.warn("[Measurement] Failed to load PDF scales", error); + setPageMeasureScales(null); + } + }); + + return () => { + cancelled = true; + }; + }, [currentFileKey, effectiveFile?.file]); + + useEffect(() => { + if (!rulerOverlayRef.current || !currentFileKey) return; + + const unsubscribe = rulerOverlayRef.current.onMeasurementsChange( + (newMeasurements: Measurement[]) => { + const validMeasurements = newMeasurements.filter((measurement) => + validateMeasurement(measurement), + ); + + setMeasurementsByFileId((prev) => + new Map(prev).set(currentFileKey, validMeasurements), + ); + persistMeasurements(currentFileKey, validMeasurements); + }, + ); + + return () => { + if (typeof unsubscribe === "function") { + unsubscribe(); + } + }; + }, [currentFileKey, rulerOverlayRef]); + + return { + isRulerActive, + setIsRulerActive: handleSetRulerActive, + pageMeasureScales, + customScale, + handleSetCustomScale, + isScaleCalibrationActive, + scaleCalibrationMeasurement, + startScaleCalibration, + cancelScaleCalibration, + handleScaleCalibrationMeasurement, + applyScaleCalibration, + }; +} diff --git a/frontend/editor/src/core/utils/measurementPreferences.ts b/frontend/editor/src/core/utils/measurementPreferences.ts new file mode 100644 index 0000000000..c339ed64f8 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementPreferences.ts @@ -0,0 +1,24 @@ +// Persist calibration unit preference across sessions +const STORAGE_KEY_LAST_CALIBRATION_UNIT = "stirling_calibration_last_unit"; + +export function getLastCalibrationUnit(defaultUnit: string): string { + try { + const stored = localStorage.getItem(STORAGE_KEY_LAST_CALIBRATION_UNIT); + return stored && stored.trim() ? stored : defaultUnit; + } catch { + // Storage unavailable - private browsing or quota exceeded + return defaultUnit; + } +} + +export function setLastCalibrationUnit(unit: string): void { + try { + localStorage.setItem(STORAGE_KEY_LAST_CALIBRATION_UNIT, unit); + } catch (error) { + // Storage unavailable - preference won't be retained + console.debug( + "[MeasurementPreferences] Unable to persist unit preference:", + error, + ); + } +} diff --git a/frontend/editor/src/core/utils/measurementTypes.ts b/frontend/editor/src/core/utils/measurementTypes.ts new file mode 100644 index 0000000000..caa47ff876 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementTypes.ts @@ -0,0 +1,45 @@ +// Page coordinates with absolute page index +export interface PagePoint { + pageIndex: number; + x: number; + y: number; +} + +// Real-world units per PDF point (factor) vs. architectural ratio for display +export interface MeasureScale { + factor: number; // Real-world units per PDF point + ratio: number | null; // Architectural ratio (e.g., 100 for "1:100") - display only + unit: string; // m, cm, mm, km, ft, in, yd, mi +} + +export type MeasureScaleLike = MeasureScale; + +// Calibration result with full context for audit trail +export interface CalibrationMetadata { + pdfDistancePts: number; // PDF space distance in points + realDistance: number; // User-specified real-world distance + scale: MeasureScale; // Resulting calculated scale + timestamp: string; // ISO 8601 format + unitUsed: string; // Unit active during calibration +} + +// Single measurement between two page points on same page +export interface Measurement { + id: string; + start: PagePoint; + end: PagePoint; +} + +// Viewport area with its own scale (for multi-region PDFs) +export interface ViewportScale { + bbox: [number, number, number, number] | null; // PDF user space or null for entire page + scale: MeasureScale; +} + +// Scale information for a single page with all viewports +export interface PageScaleInfo { + viewports: ViewportScale[]; + pageHeight: number; // PDF points - used to flip screen-y to PDF-y +} + +export type PageMeasureScales = Map; diff --git a/frontend/editor/src/core/utils/measurementUtils.test.ts b/frontend/editor/src/core/utils/measurementUtils.test.ts new file mode 100644 index 0000000000..33cf3f5c2d --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { + POINT_TO_UNIT, + calculateCalibratedScale, + calculateScaleFactor, + convertUnit, + deriveRatioFromFactor, + parsePresetRatio, +} from "@app/utils/measurementUtils"; + +describe("measurementUtils", () => { + describe("calculateScaleFactor", () => { + test("calculates real-world units per PDF point from a scale ratio", () => { + expect(calculateScaleFactor(100, "m")).toBeCloseTo(POINT_TO_UNIT.m * 100); + expect(calculateScaleFactor(50, " cm ")).toBeCloseTo( + POINT_TO_UNIT.cm * 50, + ); + expect(calculateScaleFactor(12, "FT")).toBeCloseTo(POINT_TO_UNIT.ft * 12); + }); + + test("rejects invalid scale ratios", () => { + expect(() => calculateScaleFactor(0, "m")).toThrow("Invalid scale ratio"); + expect(() => calculateScaleFactor(-1, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.NaN, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.POSITIVE_INFINITY, "m")).toThrow( + "Invalid scale ratio", + ); + }); + + test("rejects unsupported units", () => { + expect(() => calculateScaleFactor(100, "px")).toThrow("Unsupported unit"); + }); + }); + + describe("convertUnit", () => { + test("converts representative metric and imperial values", () => { + expect(convertUnit(1, "m", "cm")).toBeCloseTo(100); + expect(convertUnit(12, "in", "ft")).toBeCloseTo(1); + expect(convertUnit(3, "ft", "yd")).toBeCloseTo(1); + expect(convertUnit(1, "ft", "m")).toBeCloseTo(0.3048); + }); + + test("returns null for invalid values or unsupported units", () => { + expect(convertUnit(Number.NaN, "m", "cm")).toBeNull(); + expect(convertUnit(Number.POSITIVE_INFINITY, "m", "cm")).toBeNull(); + expect(convertUnit(1, "px", "cm")).toBeNull(); + expect(convertUnit(1, "m", "px")).toBeNull(); + }); + }); + + describe("parsePresetRatio", () => { + test("parses supported preset ratios", () => { + expect(parsePresetRatio("1:5")).toBe(5); + expect(parsePresetRatio("1:100")).toBe(100); + expect(parsePresetRatio(" 1 : 150 ")).toBe(150); + }); + + test("returns null for malformed or non-positive presets", () => { + expect(parsePresetRatio("2:100")).toBeNull(); + expect(parsePresetRatio("1:0")).toBeNull(); + expect(parsePresetRatio("1:-10")).toBeNull(); + expect(parsePresetRatio("1:not-a-number")).toBeNull(); + expect(parsePresetRatio("bad")).toBeNull(); + expect(parsePresetRatio("1:10:20")).toBeNull(); + }); + }); + + describe("deriveRatioFromFactor", () => { + test("recovers the scale ratio from a factor and unit", () => { + const factor = calculateScaleFactor(100, "m"); + + expect(deriveRatioFromFactor(factor, "m")).toBeCloseTo(100); + }); + + test("returns null for invalid factors or unsupported units", () => { + expect(deriveRatioFromFactor(0, "m")).toBeNull(); + expect(deriveRatioFromFactor(-1, "m")).toBeNull(); + expect(deriveRatioFromFactor(Number.NaN, "m")).toBeNull(); + expect(deriveRatioFromFactor(1, "px")).toBeNull(); + }); + }); + + describe("calculateCalibratedScale", () => { + test("calculates a calibrated scale from a known physical distance", () => { + const scale = calculateCalibratedScale(72, 1, "in"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.in); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("in"); + }); + + test("calculates architectural ratios for metric calibration", () => { + const scale = calculateCalibratedScale(72, 0.0254, "m"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.m); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("m"); + }); + + test("rejects invalid calibration inputs", () => { + expect(() => calculateCalibratedScale(0, 1, "m")).toThrow( + "Invalid PDF distance", + ); + expect(() => calculateCalibratedScale(72, 0, "m")).toThrow( + "Invalid real-world distance", + ); + expect(() => calculateCalibratedScale(72, 1, "px")).toThrow( + "Unsupported unit", + ); + }); + }); +}); diff --git a/frontend/editor/src/core/utils/measurementUtils.ts b/frontend/editor/src/core/utils/measurementUtils.ts new file mode 100644 index 0000000000..e35645fb2b --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.ts @@ -0,0 +1,398 @@ +// PDF point to real-world unit conversions + +import type { + Measurement, + MeasureScale, + PagePoint, + CalibrationMetadata, +} from "@app/utils/measurementTypes"; + +// 1 PDF point in meters (1/72 inch) +const POINT_TO_METERS = 0.0254 / 72; + +// Conversion factors: units per PDF point +export const POINT_TO_UNIT = { + m: POINT_TO_METERS, + cm: POINT_TO_METERS * 100, + mm: POINT_TO_METERS * 1000, + km: POINT_TO_METERS / 1000, + ft: POINT_TO_METERS / 0.3048, + in: POINT_TO_METERS / 0.0254, + yd: POINT_TO_METERS / 0.9144, + mi: POINT_TO_METERS / 1609.344, +} as const; + +// Valid measurement units from POINT_TO_UNIT +export type MeasurementUnit = keyof typeof POINT_TO_UNIT; + +function normalizeUnit(unit: string): string { + return unit.toLowerCase().trim(); +} + +function isMeasurementUnit(unit: string): unit is MeasurementUnit { + return Object.hasOwn(POINT_TO_UNIT, unit); +} + +export function getUnitFactor(unit: string): number | undefined { + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + return undefined; + } + return POINT_TO_UNIT[normalized]; +} + +export function calculateScaleFactor(ratio: number, unit: string): number { + if (!Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`Invalid scale ratio: ${ratio}`); + } + + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + throw new Error(`Unsupported unit: ${unit}`); + } + + return POINT_TO_UNIT[normalized] * ratio; +} + +export function generateScaleLabel(ratio: number | null, unit: string): string { + if (ratio === null || ratio === undefined) { + return unit; + } + const display = Number.isInteger(ratio) + ? ratio.toString() + : ratio.toFixed(2).replace(/\.?0+$/, ""); + return `1:${display} (${unit})`; +} + +// Imperial units +const IMPERIAL_UNITS = ["ft", "in", "yd", "mi"] as const; +export function isImperialUnit(unit: string): boolean { + const normalized = normalizeUnit(unit); + return isMeasurementUnit(normalized) + ? (IMPERIAL_UNITS as readonly MeasurementUnit[]).includes(normalized) + : false; +} + +export function convertUnit( + value: number, + sourceUnit: string, + targetUnit: string, +): number | null { + if (!Number.isFinite(value)) { + return null; + } + + const src = normalizeUnit(sourceUnit); + const tgt = normalizeUnit(targetUnit); + + if (!isMeasurementUnit(src) || !isMeasurementUnit(tgt)) { + return null; + } + + const sourceFactor = POINT_TO_UNIT[src]; + const targetFactor = POINT_TO_UNIT[tgt]; + + return value * (targetFactor / sourceFactor); +} + +export function parsePresetRatio(preset: string): number | null { + const parts = preset.split(":"); + + // Must have exactly 2 parts and first part must be "1" + if (parts.length !== 2 || parts[0].trim() !== "1") { + return null; + } + + const value = Number(parts[1]); + return Number.isFinite(value) && value > 0 ? value : null; +} + +// UI dropdown options - shared across components +export const UNIT_OPTIONS = [ + { value: "m", label: "Meters (m)" }, + { value: "cm", label: "Centimeters (cm)" }, + { value: "mm", label: "Millimeters (mm)" }, + { value: "km", label: "Kilometers (km)" }, + { value: "ft", label: "Feet (ft)" }, + { value: "in", label: "Inches (in)" }, + { value: "yd", label: "Yards (yd)" }, + { value: "mi", label: "Miles (mi)" }, +] as const; + +const MAX_SESSION_ENTRIES = 50; +const TRIMMED_SESSION_ENTRIES = 40; + +/** + * Detect quota exceeded errors across browser implementations. + * Handles: name "QuotaExceededError", code 22 (legacy), "NS_ERROR_DOM_QUOTA_REACHED" + * + * Note: DOMException may not be instanceof Error in all browsers, + * so we check by shape and properties rather than type. + * Note: DOMException.code is deprecated but kept for legacy browser support. + */ +function isQuotaExceededError(error: unknown): boolean { + if (error === null || error === undefined) return false; + + // Check if it's a DOMException when available (standard) + if (typeof DOMException !== "undefined" && error instanceof DOMException) { + if (error.name === "QuotaExceededError") return true; + } + + // Fallback: check by shape for any object with name/code properties + if (typeof error === "object") { + const err = error as Record; + + // Modern standard: check name property (works in all modern browsers) + if (err.name === "QuotaExceededError") return true; + if (err.name === "NS_ERROR_DOM_QUOTA_REACHED") return true; + + // Legacy support: check deprecated code property for very old browsers + // Use Object.hasOwn for safe own-property check + if (Object.hasOwn(err, "code") && err.code === 22) return true; + } + + return false; +} + +// Load entries from sessionStorage +export function loadSessionMap(key: string): Record { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return {}; + + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return {}; + } + + return data as Record; + } catch { + // Silently return empty object on parse error + try { + sessionStorage.removeItem(key); + } catch { + // Ignore cleanup errors + } + return {}; + } +} + +// Save entry to sessionStorage with quota management. +export function saveSessionMap( + key: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, +): void { + if (!fileKey) return; + + try { + const existing: Record = { + ...loadSessionMap(key), + }; + + // Delete first to move fileKey to end (maintains insertion order recency) + delete existing[fileKey]; + existing[fileKey] = value; + + // Trim back below the max to avoid pruning again on every subsequent save. + const keys = Object.keys(existing); + if (keys.length > MAX_SESSION_ENTRIES) { + const entriesToDelete = keys.slice( + 0, + keys.length - TRIMMED_SESSION_ENTRIES, + ); + entriesToDelete.forEach((k) => delete existing[k]); + } + + sessionStorage.setItem(key, JSON.stringify(existing)); + } catch (e) { + // Quota exceeded - try clearing and retrying (handles cross-browser error variants) + if (isQuotaExceededError(e)) { + try { + sessionStorage.removeItem(key); + // Retry with fresh storage + const fresh: Record = { [fileKey]: value }; + sessionStorage.setItem(key, JSON.stringify(fresh)); + } catch { + // Silently ignore if retry fails - data loss is acceptable + } + } + // Silently ignore other storage errors + } +} + +// Validation helpers + +export function validatePagePoint(obj: unknown): obj is PagePoint { + if (typeof obj !== "object" || obj === null) return false; + + const pt = obj as Record; + return ( + typeof pt.pageIndex === "number" && + Number.isFinite(pt.pageIndex) && + pt.pageIndex >= 0 && + typeof pt.x === "number" && + Number.isFinite(pt.x) && + typeof pt.y === "number" && + Number.isFinite(pt.y) + ); +} + +// MeasureScale can be null (reset) or valid object +export function validateMeasureScale(obj: unknown): obj is MeasureScale | null { + // null is allowed (reset to default) + if (obj === null) return true; + + if (typeof obj !== "object") return false; + + const s = obj as Record; + + // Validate factor: must be positive finite number + if ( + typeof s.factor !== "number" || + !Number.isFinite(s.factor) || + s.factor <= 0 + ) { + return false; + } + + // Validate ratio: optional, but if present must be positive finite number + if ( + s.ratio !== null && + (typeof s.ratio !== "number" || !Number.isFinite(s.ratio) || s.ratio <= 0) + ) { + return false; + } + + // Validate unit: must be non-empty string and exist in POINT_TO_UNIT + if (typeof s.unit !== "string" || s.unit.trim().length === 0) { + return false; + } + + const normalized = normalizeUnit(s.unit); + if (!isMeasurementUnit(normalized)) { + return false; + } + + return true; +} + +// Reject cross-page measurements +export function validateMeasurement(obj: unknown): obj is Measurement { + if (typeof obj !== "object" || obj === null) return false; + + const m = obj as Record; + + // Validate structure + if ( + !( + typeof m.id === "string" && + m.id.trim().length > 0 && + validatePagePoint(m.start) && + validatePagePoint(m.end) + ) + ) { + return false; + } + + // Reject cross-page measurements + const start = m.start as PagePoint; + const end = m.end as PagePoint; + if (start.pageIndex !== end.pageIndex) { + return false; + } + + return true; +} + +export function formatPaperDistance(distancePts: number): string { + if (!Number.isFinite(distancePts) || distancePts < 0) { + return "0 mm"; + } + + const inches = distancePts / 72; + const mm = inches * 25.4; + + if (mm < 100) { + return `${mm.toFixed(1)} mm`; + } + if (mm < 1000) { + return `${(mm / 10).toFixed(1)} cm`; + } + return `${(mm / 1000).toFixed(2)} m`; +} + +export function validateRealDistance(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const num = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(num) || num <= 0) { + return null; + } + + return num; +} + +export function deriveRatioFromFactor( + factor: number, + unit: string, +): number | null { + if (!Number.isFinite(factor) || factor <= 0) { + return null; + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + return null; + } + + // ratio = factor / baseFactor + const ratio = factor / baseFactor; + return Number.isFinite(ratio) && ratio > 0 ? ratio : null; +} + +export function calculateCalibratedScale( + pdfDistancePts: number, + realDistance: number, + unit: string, +): MeasureScale { + if (!Number.isFinite(pdfDistancePts) || pdfDistancePts <= 0) { + throw new Error("Invalid PDF distance (must be positive)"); + } + + if (!Number.isFinite(realDistance) || realDistance <= 0) { + throw new Error("Invalid real-world distance (must be positive)"); + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + throw new Error(`Unsupported unit: ${unit}`); + } + + const factor = realDistance / pdfDistancePts; + const ratio = deriveRatioFromFactor(factor, unit); + + return { + factor, + ratio, + unit, + }; +} + +export function createCalibrationMetadata( + pdfDistancePts: number, + realDistance: number, + scale: MeasureScale, + unitUsed: string, +): CalibrationMetadata { + return { + pdfDistancePts, + realDistance, + scale, + timestamp: new Date().toISOString(), + unitUsed, + }; +} diff --git a/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts new file mode 100644 index 0000000000..3c40e3209f --- /dev/null +++ b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts @@ -0,0 +1,215 @@ +import type { + PDFArray, + PDFDict, + PDFHexString, + PDFName, + PDFNumber, + PDFString, +} from "@cantoo/pdf-lib"; +import type { + MeasureScale, + PageMeasureScales, + PageScaleInfo, + ViewportScale, +} from "@app/utils/measurementTypes"; +import { getUnitFactor } from "@app/utils/measurementUtils"; + +type PdfMeasurementObjects = Pick< + typeof import("@cantoo/pdf-lib"), + | "PDFArray" + | "PDFDict" + | "PDFHexString" + | "PDFName" + | "PDFNumber" + | "PDFString" +>; + +function asPdfArray( + value: unknown, + { PDFArray }: PdfMeasurementObjects, +): PDFArray | null { + return value instanceof PDFArray ? value : null; +} + +function asPdfDict( + value: unknown, + { PDFDict }: PdfMeasurementObjects, +): PDFDict | null { + return value instanceof PDFDict ? value : null; +} + +function asPdfNumber( + value: unknown, + { PDFNumber }: PdfMeasurementObjects, +): PDFNumber | null { + return value instanceof PDFNumber ? value : null; +} + +function asPdfText( + value: unknown, + { PDFHexString, PDFName, PDFString }: PdfMeasurementObjects, +): PDFHexString | PDFName | PDFString | null { + if ( + value instanceof PDFString || + value instanceof PDFHexString || + value instanceof PDFName + ) { + return value; + } + return null; +} + +function lookupArray( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFArray | null { + return asPdfArray(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupDict( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFDict | null { + return asPdfDict(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupNumber( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): number | null { + return ( + asPdfNumber( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.asNumber() ?? null + ); +} + +function lookupText( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): string | null { + return ( + asPdfText( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.decodeText() ?? null + ); +} + +function readArrayNumber( + array: PDFArray, + index: number, + pdfObjects: PdfMeasurementObjects, +): number | null { + return asPdfNumber(array.lookup(index), pdfObjects)?.asNumber() ?? null; +} + +function readBBox( + bboxArray: PDFArray | null, + pdfObjects: PdfMeasurementObjects, +): ViewportScale["bbox"] { + if (!bboxArray || bboxArray.size() < 4) { + return null; + } + + const x0 = readArrayNumber(bboxArray, 0, pdfObjects); + const y0 = readArrayNumber(bboxArray, 1, pdfObjects); + const x1 = readArrayNumber(bboxArray, 2, pdfObjects); + const y1 = readArrayNumber(bboxArray, 3, pdfObjects); + + if (x0 === null || y0 === null || x1 === null || y1 === null) { + return null; + } + + return [x0, y0, x1, y1]; +} + +function parseScale( + measureDict: PDFDict | null, + pdfObjects: PdfMeasurementObjects, +): MeasureScale | null { + if (!measureDict) return null; + + const fmtArray = + lookupArray(measureDict, "D", pdfObjects) ?? + lookupArray(measureDict, "X", pdfObjects); + if (!fmtArray || fmtArray.size() === 0) return null; + + const firstFmt = asPdfDict(fmtArray.lookup(0), pdfObjects); + if (!firstFmt) return null; + + const factor = lookupNumber(firstFmt, "C", pdfObjects); + if (factor === null || factor <= 0) return null; + + const unit = lookupText(firstFmt, "U", pdfObjects)?.trim().toLowerCase(); + if (!unit) return null; + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) return null; + + const ratio = factor / baseFactor; + return { factor, ratio, unit }; +} + +export async function extractPageMeasureScales( + file: Blob, +): Promise { + try { + const pdfLib = await import("@cantoo/pdf-lib"); + const { PDFDocument, PDFArray, PDFDict, PDFName } = pdfLib; + const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { + ignoreEncryption: true, + }); + + const result: PageMeasureScales = new Map(); + + for (let i = 0; i < pdfDoc.getPageCount(); i++) { + const page = pdfDoc.getPage(i); + const pageHeight = page.getHeight(); + const viewports: ViewportScale[] = []; + + const vpObj = page.node.lookup(PDFName.of("VP")); + if (vpObj instanceof PDFArray) { + for (let j = 0; j < vpObj.size(); j++) { + const vpEntry = vpObj.lookup(j); + if (!(vpEntry instanceof PDFDict)) continue; + + const scale = parseScale( + lookupDict(vpEntry, "Measure", pdfLib), + pdfLib, + ); + if (!scale) continue; + + viewports.push({ + bbox: readBBox(lookupArray(vpEntry, "BBox", pdfLib), pdfLib), + scale, + }); + } + } + + if (viewports.length === 0) { + const scale = parseScale( + lookupDict(page.node, "Measure", pdfLib), + pdfLib, + ); + if (scale) { + viewports.push({ bbox: null, scale }); + } + } + + if (viewports.length > 0) { + result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); + } + } + + return result.size > 0 ? result : null; + } catch (error) { + console.warn("[Measurement] Failed to extract PDF scales", error); + return null; + } +}