Added Measurement Scale Support for Architectural Drawings (#6121) (#6215)

Fix #6121 
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes (#6121 )
-->
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 : 

<img width="1316" height="515" alt="Captura de tela de 2026-05-31
21-57-53"
src="https://github.com/user-attachments/assets/df0ffb7d-4546-450c-8380-409658852a87"
/>

Current calibration input : 

<img width="1021" height="522" alt="Captura de tela de 2026-05-31
21-59-52"
src="https://github.com/user-attachments/assets/e17c14e7-fec7-42e9-af48-33ef825acb6c"
/>

Example of usage : 

<img width="1316" height="760" alt="Captura de tela de 2026-05-31
22-31-01"
src="https://github.com/user-attachments/assets/fcd3578e-1eaa-42d3-b30b-3fc87c796f90"
/>


### 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>
This commit is contained in:
Matheus Saito
2026-08-07 09:53:33 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent 84b0b69fe7
commit fa11034a99
18 changed files with 3536 additions and 818 deletions
@@ -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"
@@ -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"
@@ -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<PageMeasureScales | null> {
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<PageMeasureScales | null>(null);
// Ruler / measurement tool state is handled by the dedicated hook.
const rulerOverlayRef = useRef<RulerOverlayHandle | null>(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}
/>
<RulerOverlay
ref={rulerOverlayRef}
containerRef={pdfContainerRef}
isActive={isRulerActive}
pageMeasureScales={pageMeasureScales}
customScale={customScale}
isCalibrationActive={isScaleCalibrationActive}
onCalibrationMeasure={handleScaleCalibrationMeasurement}
/>
</Box>
<ScaleCalibrationDialog
opened={!!scaleCalibrationMeasurement}
measurement={scaleCalibrationMeasurement}
defaultUnit={customScale?.unit ?? "m"}
onApplyScale={applyScaleCalibration}
onClose={cancelScaleCalibration}
/>
</>
)}
@@ -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<SignatureOverlayAPI | null>;
}
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 (
<div
data-page-index={pageIndex}
data-page-width={width}
data-page-height={height}
data-page-rotation={pageRotation}
style={{
width,
height,
position: "relative",
overflow: "hidden", // clip overlays (buttons, fields) that extend beyond the page rect
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{children}
</div>
);
}
export function LocalEmbedPDF({
file,
url,
@@ -1023,25 +1076,11 @@ export function LocalEmbedPDF({
documentId={documentId}
pageIndex={pageIndex}
>
<div
data-page-index={pageIndex}
data-page-width={width}
data-page-height={height}
style={{
width,
height,
position: "relative",
overflow: "hidden", // clip overlays (buttons, fields) that extend beyond the page rect
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
<ViewerPageContainer
documentId={documentId}
pageIndex={pageIndex}
width={width}
height={height}
>
<div
style={{
@@ -1173,7 +1212,7 @@ export function LocalEmbedPDF({
onSelect={setSelectedSignatureId}
/>
)}
</div>
</ViewerPageContainer>
</PagePointerProvider>
</Rotate>
);
@@ -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;
}
@@ -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<SVGGElement>;
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<string> {
const visibleIds = new Set<string>();
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 (
<g
data-ruler-interactive="true"
onPointerEnter={() => {
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",
}}
>
<line
x1={startS.x}
y1={startS.y}
x2={endS.x}
y2={endS.y}
stroke="transparent"
strokeWidth={LINE_HIT_WIDTH}
strokeLinecap="round"
pointerEvents="stroke"
/>
<line
x1={startS.x}
y1={startS.y}
x2={endS.x}
y2={endS.y}
stroke="#1e88e5"
strokeWidth={sw}
strokeLinecap="round"
/>
<line
x1={startS.x + (nx * TICK) / 2}
y1={startS.y + (ny * TICK) / 2}
x2={startS.x - (nx * TICK) / 2}
y2={startS.y - (ny * TICK) / 2}
stroke="#1e88e5"
strokeWidth={sw}
strokeLinecap="round"
/>
<line
x1={endS.x + (nx * TICK) / 2}
y1={endS.y + (ny * TICK) / 2}
x2={endS.x - (nx * TICK) / 2}
y2={endS.y - (ny * TICK) / 2}
stroke="#1e88e5"
strokeWidth={sw}
strokeLinecap="round"
/>
<circle
cx={startS.x}
cy={startS.y}
r={RULER_DOT_RADIUS}
fill="#1e88e5"
stroke="white"
strokeWidth={2}
/>
<circle
cx={endS.x}
cy={endS.y}
r={RULER_DOT_RADIUS}
fill="#1e88e5"
stroke="white"
strokeWidth={2}
/>
{showLabel && (
<g style={{ pointerEvents: "all", cursor: "pointer" }}>
<rect
x={labelCenter.x - lw / 2 - 4}
y={labelCenter.y - lh / 2 - 4}
width={lw + 8}
height={lh + 8}
fill="transparent"
stroke="none"
/>
<rect
x={labelCenter.x - lw / 2}
y={labelCenter.y - lh / 2}
width={lw}
height={lh}
rx={isCompactIdle ? 4 : 5}
fill={isCompactIdle ? "rgba(255,255,255,0.94)" : "white"}
stroke="#1e88e5"
strokeWidth={isCompactIdle ? 1 : 1.5}
filter="url(#ruler-shadow)"
/>
{isSelected && measureScale ? (
<>
<text
x={labelCenter.x}
y={labelCenter.y - Math.round(17 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#1e88e5"
fontSize={12}
fontFamily={mono}
fontWeight={600}
style={{ userSelect: "none" }}
>
{hoverLine1}
</text>
<text
x={labelCenter.x}
y={labelCenter.y}
textAnchor="middle"
dominantBaseline="middle"
fill="#546e7a"
fontSize={11}
fontFamily={mono}
fontWeight={500}
style={{ userSelect: "none" }}
>
{hoverLine2}
</text>
<text
x={labelCenter.x}
y={labelCenter.y + Math.round(17 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#5c6bc0"
fontSize={10}
fontFamily={mono}
fontWeight={500}
style={{ userSelect: "none" }}
>
{contextLabel}
</text>
</>
) : isSelected ? (
<>
<text
x={labelCenter.x}
y={labelCenter.y - Math.round(6 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#1e88e5"
fontSize={12}
fontFamily={mono}
fontWeight={600}
style={{ userSelect: "none" }}
>
{hoverLine1}
</text>
<text
x={labelCenter.x}
y={labelCenter.y + Math.round(13 * zoomScale)}
textAnchor="middle"
dominantBaseline="middle"
fill="#5c6bc0"
fontSize={11}
fontFamily={mono}
fontWeight={500}
style={{ userSelect: "none" }}
>
{contextLabel}
</text>
</>
) : (
<text
x={labelCenter.x}
y={labelCenter.y + 1}
textAnchor="middle"
dominantBaseline="middle"
fill="#1e88e5"
fontSize={isCompactIdle ? 10 : 12}
fontFamily={mono}
fontWeight={600}
style={{ userSelect: "none" }}
>
{distLabel}
</text>
)}
</g>
)}
{showDelete && (
<>
<line
x1={endS.x}
y1={endS.y}
x2={deleteCenter.x}
y2={deleteCenter.y}
stroke="transparent"
strokeWidth={DEL_R * 2}
strokeLinecap="round"
pointerEvents="stroke"
/>
<g
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onDelete(id);
}}
>
<circle
cx={deleteCenter.x}
cy={deleteCenter.y}
r={DEL_R}
fill="#ef5350"
stroke="white"
strokeWidth={1.5}
/>
<text
x={deleteCenter.x}
y={deleteCenter.y}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={12}
fontWeight={700}
style={{ userSelect: "none" }}
>
×
</text>
</g>
</>
)}
</g>
);
}
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 (
<g>
<line
x1={startS.x}
y1={startS.y}
x2={endS.x}
y2={endS.y}
stroke="#1e88e5"
strokeWidth={2}
strokeDasharray="7 4"
strokeLinecap="round"
opacity={0.85}
/>
<line
x1={startS.x + (nx * TICK) / 2}
y1={startS.y + (ny * TICK) / 2}
x2={startS.x - (nx * TICK) / 2}
y2={startS.y - (ny * TICK) / 2}
stroke="#1e88e5"
strokeWidth={2}
strokeLinecap="round"
/>
{d > 4 && (
<g>
<rect
x={labelCenter.x - lw / 2}
y={labelCenter.y - lh / 2}
width={lw}
height={lh}
rx={5}
fill="#1e88e5"
stroke="white"
strokeWidth={1}
/>
<text
x={labelCenter.x}
y={labelCenter.y - 6}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={12}
fontFamily="'Roboto Mono','Consolas',monospace"
fontWeight={600}
style={{ userSelect: "none" }}
>
{distLabel}
</text>
<text
x={labelCenter.x}
y={labelCenter.y + 13}
textAnchor="middle"
dominantBaseline="middle"
fill="rgba(255,255,255,0.85)"
fontSize={11}
fontFamily="'Roboto Mono','Consolas',monospace"
fontWeight={500}
style={{ userSelect: "none" }}
>
{`${ang.toFixed(1)}°`}
</text>
</g>
)}
</g>
);
}
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 (
<>
<g ref={pageContentRef}>
{orderedMeasurements.map((item) => {
const { measurement, startS, endS, distPts, measureScale } = item;
return (
<MeasurementLine
key={measurement.id}
id={measurement.id}
startS={startS}
endS={endS}
distPts={distPts}
isSelected={
!isInteractionPassthroughActive && selectedId === measurement.id
}
isHovered={
!isInteractionPassthroughActive && hoveredId === measurement.id
}
onSelect={onSelect}
onDelete={onDelete}
onHoverChange={onHoverChange}
measureScale={measureScale}
zoom={zoom}
showIdleLabel={getShouldShowIdleLabel(measurement.id)}
expandLabelOnHover={labelVisibilityMode !== "showAll"}
isInteractionPassthroughActive={isInteractionPassthroughActive}
labels={measurementLineLabels}
/>
);
})}
{firstPoint && (
<circle
cx={firstPoint.x}
cy={firstPoint.y}
r={RULER_DOT_RADIUS}
fill="#1e88e5"
stroke="white"
strokeWidth={2}
/>
)}
</g>
{liveLine && (
<LiveLine
startS={liveLine.startS}
endS={liveLine.endS}
zoom={zoom}
measureScale={liveLine.measureScale}
/>
)}
{cursor && (
<g opacity={0.75}>
<line
x1={cursor.x - 12}
y1={cursor.y}
x2={cursor.x + 12}
y2={cursor.y}
stroke="#1e88e5"
strokeWidth={1.5}
/>
<line
x1={cursor.x}
y1={cursor.y - 12}
x2={cursor.x}
y2={cursor.y + 12}
stroke="#1e88e5"
strokeWidth={1.5}
/>
<circle cx={cursor.x} cy={cursor.y} r={2} fill="#1e88e5" />
</g>
)}
{measurements.length > 0 && (
<>
<g
data-ruler-interactive="true"
data-ruler-control="true"
style={{ pointerEvents: "all", cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onClearAll();
}}
>
<rect
x={8}
y={8}
width={88}
height={26}
rx={5}
fill="rgba(239,83,80,0.9)"
stroke="white"
strokeWidth={1}
/>
<text
x={52}
y={25}
textAnchor="middle"
fill="white"
fontSize={12}
fontFamily="sans-serif"
fontWeight={600}
style={{ userSelect: "none" }}
>
{t("ruler.clearAll", "Clear all")}
</text>
</g>
<g
data-ruler-interactive="true"
data-ruler-control="true"
style={{ pointerEvents: "all", cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onCycleLabelVisibilityMode();
}}
>
<rect
x={8}
y={40}
width={LABEL_MODE_BUTTON_WIDTH}
height={26}
rx={5}
fill="rgba(30,136,229,0.9)"
stroke="white"
strokeWidth={1}
/>
<text
x={8 + LABEL_MODE_BUTTON_WIDTH / 2}
y={57}
textAnchor="middle"
fill="white"
fontSize={12}
fontFamily="sans-serif"
fontWeight={600}
style={{ userSelect: "none" }}
>
{labelModeButtonLabel}
</text>
</g>
</>
)}
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -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<TooltipProps["position"]>;
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<HTMLButtonElement>(null);
return (
<Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
withinPortal
>
<Popover.Target>
<div style={{ display: "inline-flex" }}>
<Tooltip
content={label}
position={tooltipPosition}
offset={12}
arrow
portalTarget={document.body}
>
<ActionIcon
ref={scalePopoverRef}
variant="primary"
className="workbench-bar-action-icon"
disabled={disabled}
aria-label={label}
>
<SettingsIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
</Popover.Target>
<Popover.Dropdown>
<ScaleSettingsPanel
currentScale={currentScale}
onApplyScale={(scale) => {
onApplyScale?.(scale);
}}
onResetScale={() => {
onResetScale?.();
}}
onStartCalibration={onStartCalibration}
onCancelCalibration={onCancelCalibration}
isCalibrationActive={isCalibrationActive}
onClose={() => {
scalePopoverRef.current?.click();
}}
/>
</Popover.Dropdown>
</Popover>
);
}
@@ -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<number | null>(null);
const [unit, setUnit] = useState(() => getLastCalibrationUnit(defaultUnit));
const [error, setError] = useState<string | null>(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 (
<Modal
opened={opened}
onClose={onClose}
title={t("scaleSettings.calibrationTitle", "Calibrate Scale")}
centered
size="sm"
>
<Stack gap="md">
{/* Show paper distance automatically */}
{measurement && (
<Text size="sm" c="dimmed">
{t(
"scaleSettings.calibrationPaperDistance",
"Measured page distance: {{distance}}",
{
distance: formatPaperDistance(measurement.pdfDistancePts),
},
)}
</Text>
)}
{/* Input: real distance + unit selector */}
<Group grow align="flex-start">
<NumberInput
label={t("scaleSettings.realDistance", "Real-world distance")}
placeholder={t(
"scaleSettings.realDistancePlaceholder",
"e.g., 5 (actual distance in {{unit}})",
{ unit },
)}
value={realDistance ?? undefined}
onChange={handleRealDistanceChange}
min={0}
step={0.1}
decimalScale={4}
error={error}
/>
<Select
label={t("scaleSettings.unit", "Unit")}
data={UNIT_OPTIONS}
value={unit}
onChange={handleUnitChange}
searchable
/>
</Group>
{/* Preview: show calculated scale */}
{previewScale && (
<Text size="sm" c="blue">
{t(
"scaleSettings.calculatedScale",
"Calculated scale (1 page unit = X real units)",
)}
: {generateScaleLabel(previewScale.ratio, previewScale.unit)}
</Text>
)}
{/* Actions: Cancel or Apply */}
<Group justify="flex-end">
<Button variant="secondary" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={handleApply}>
{t("scaleSettings.applyCalibration", "Apply Calibration")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,321 @@
import React, { useCallback, useEffect, useState } from "react";
import { Group, NumberInput, Select, Stack, Text, Grid } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import type { MeasureScale } from "@app/utils/measurementTypes";
import {
generateScaleLabel,
parsePresetRatio,
calculateScaleFactor,
UNIT_OPTIONS,
} from "@app/utils/measurementUtils";
// ─── Constants ─────────────────────────────────────────────────────────────
// Preset scales as architectural ratios (e.g., "1:100" = 1 page unit = 100 real-world units)
const PRESET_SCALES = ["1:5", "1:10", "1:20", "1:50", "1:100", "1:150"];
// ─── Component ────────────────────────────────────────────────────────────
interface ScaleSettingsPanelProps {
onApplyScale: (scale: MeasureScale) => void;
onResetScale?: () => void;
onStartCalibration?: () => void;
onCancelCalibration?: () => void;
isCalibrationActive?: boolean;
currentScale?: MeasureScale | null;
onClose?: () => void;
}
export function ScaleSettingsPanel({
onApplyScale,
onResetScale,
onStartCalibration,
onCancelCalibration,
isCalibrationActive = false,
currentScale,
onClose,
}: ScaleSettingsPanelProps) {
const { t } = useTranslation();
const [ratio, setRatio] = useState<string | number>(
currentScale?.ratio ?? "",
);
const [unit, setUnit] = useState<string>(currentScale?.unit || "m");
const [presetSelected, setPresetSelected] = useState<string | null>(null);
const [ratioError, setRatioError] = useState<string | null>(null);
const [unitError, setUnitError] = useState<string | null>(null);
// Helper functions to clear errors
const clearErrors = useCallback(() => {
setRatioError(null);
setUnitError(null);
}, []);
const clearRatioError = useCallback(() => {
setRatioError(null);
}, []);
const clearUnitError = useCallback(() => {
setUnitError(null);
}, []);
// Sync form fields when currentScale changes
useEffect(() => {
if (currentScale) {
// ratio can be null, so only set it if it exists
setRatio(currentScale.ratio ?? "");
setUnit(currentScale.unit);
clearErrors();
// Check if current scale matches any preset
const matchedPreset =
currentScale.ratio !== null
? PRESET_SCALES.find(
(preset) => parsePresetRatio(preset) === currentScale.ratio,
)
: null;
setPresetSelected(matchedPreset ?? null);
} else {
// Reset to defaults when no active scale
setRatio("");
setUnit("m");
setPresetSelected(null);
clearErrors();
}
}, [currentScale, clearErrors]);
const handlePresetClick = (preset: string) => {
const presetRatio = parsePresetRatio(preset);
if (presetRatio === null) return;
setPresetSelected(preset);
setRatio(presetRatio);
clearRatioError();
// Live update: apply immediately for presets
applyScale(presetRatio, unit);
// Close panel after applying preset
onClose?.();
};
const handleRatioChange = (value: string | number) => {
clearRatioError();
if (value === "") {
setRatio("");
setPresetSelected(null);
return;
}
setRatio(value);
// Clear preset selection when user enters custom value
setPresetSelected(null);
};
const parseRatioValue = (value: string | number): number | null => {
if (typeof value === "number") return Number.isNaN(value) ? null : value;
const trimmedValue = value.trim();
if (!trimmedValue) return null;
const numValue = Number(trimmedValue);
return Number.isNaN(numValue) ? null : numValue;
};
const handleUnitChange = (val: string | null) => {
if (!val) return;
setUnit(val);
clearUnitError();
// Auto-apply immediately if ratio is set (better UX consistency)
const numValue = parseRatioValue(ratio);
if (numValue && numValue > 0) {
// Close panel after auto-applying unit change
applyScale(numValue, val, true);
}
};
const validateRatio = (): number | null => {
const numValue = parseRatioValue(ratio);
if (numValue === null || numValue === undefined || isNaN(numValue)) {
setRatioError(t("scaleSettings.ratioRequired", "Ratio is required"));
return null;
}
if (numValue <= 0) {
setRatioError(
t("scaleSettings.ratioPositive", "Ratio must be greater than zero"),
);
return null;
}
return numValue;
};
const applyScale = (
scaleRatio: number,
scaleUnit: string,
shouldClose = false,
) => {
if (!scaleRatio || scaleRatio <= 0) return;
try {
// Calculate the raw factor from the ratio
const factor = calculateScaleFactor(scaleRatio, scaleUnit);
const scale: MeasureScale = {
factor,
ratio: scaleRatio,
unit: scaleUnit,
};
onApplyScale(scale);
clearUnitError();
// Close panel only if explicitly requested
if (shouldClose) {
onClose?.();
}
} catch (err) {
console.error("Invalid unit:", scaleUnit, err);
setUnitError(
t("scaleSettings.unitInvalid", "Invalid unit: {{scaleUnit}}", {
scaleUnit,
}),
);
}
};
const handleApply = () => {
const numValue = validateRatio();
if (numValue === null) return;
// Close panel after manual apply (if validation passed)
applyScale(numValue, unit, true);
};
const handleCalibrationToggle = () => {
clearErrors();
if (isCalibrationActive) {
onCancelCalibration?.();
} else {
onStartCalibration?.();
}
onClose?.();
};
return (
<Stack gap="md" style={{ padding: "0.75rem", minWidth: "300px" }}>
{/* Preset Scales Section */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("scaleSettings.presets", "Preset Scales")}
</Text>
<Grid gutter="xs">
{PRESET_SCALES.map((preset) => (
<Grid.Col span={4} key={preset}>
<Button
variant={presetSelected === preset ? "primary" : "tertiary"}
size="sm"
fontSize="xs"
fullWidth
onClick={() => handlePresetClick(preset)}
>
{preset}
</Button>
</Grid.Col>
))}
</Grid>
</div>
{/* Custom Scale Section */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("scaleSettings.customScale", "Custom Scale")}
</Text>
<Group grow>
<div>
<NumberInput
key={`ratio-${currentScale?.ratio ?? "empty"}`}
label={t("scaleSettings.ratio", "Scale Ratio")}
placeholder={t(
"scaleSettings.ratioPlaceholder",
"e.g., 100 (1 page unit = 100 real units)",
)}
value={ratio}
onChange={handleRatioChange}
min={0.1}
step={1}
size="xs"
error={ratioError}
/>
</div>
<div onMouseDown={(e) => e.stopPropagation()}>
<Select
label={t("scaleSettings.unit", "Unit")}
data={UNIT_OPTIONS}
value={unit}
onChange={handleUnitChange}
size="xs"
searchable
error={unitError}
/>
</div>
</Group>
<Text size="xs" c="dimmed" mt="xs">
{t(
"scaleSettings.ratioHelp",
"Ratio: 1 page unit = X real-world units",
)}
</Text>
</div>
<Button onClick={handleApply} fullWidth size="sm">
{t("scaleSettings.apply", "Apply Scale")}
</Button>
{/* Active Scale Display */}
<div
style={{
padding: "0.5rem",
backgroundColor: currentScale
? "rgba(30, 136, 229, 0.1)"
: "rgba(158, 158, 158, 0.1)",
borderRadius: "4px",
}}
>
<Text size="xs" c={currentScale ? "blue" : "dimmed"}>
<strong>{t("scaleSettings.activeScale", "Active Scale")}:</strong>{" "}
{currentScale && currentScale.ratio
? generateScaleLabel(currentScale.ratio, currentScale.unit)
: currentScale && !currentScale.ratio
? `${currentScale.unit} (custom)`
: t("scaleSettings.noneSet", "No custom scale set")}
</Text>
</div>
{/* Calibration Mode */}
<Button
onClick={handleCalibrationToggle}
fullWidth
variant={isCalibrationActive ? "primary" : "secondary"}
size="sm"
disabled={
isCalibrationActive ? !onCancelCalibration : !onStartCalibration
}
title={t(
"scaleSettings.calibrationTooltip",
"Measure a known distance to calculate the scale automatically",
)}
>
{isCalibrationActive
? t("scaleSettings.calibrating", "Calibrating")
: t("scaleSettings.calibrate", "Calibrate")}
</Button>
{/* Reset Button */}
{currentScale && (
<Button
onClick={() => onResetScale?.()}
fullWidth
variant="secondary"
size="sm"
>
{t("scaleSettings.reset", "Reset to Defaults")}
</Button>
)}
</Stack>
);
}
@@ -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: <SettingsIcon sx={{ fontSize: "1.5rem" }} />,
tooltip: rulerSettingsLabel,
ariaLabel: rulerSettingsLabel,
section: "top" as const,
order: 25.5,
render: ({ disabled }: { disabled?: boolean }) => (
<RulerScaleSettingsButton
disabled={disabled}
label={rulerSettingsLabel}
tooltipPosition={tooltipPosition}
currentScale={customScale}
onApplyScale={handleApplyRulerScale}
onResetScale={handleResetRulerScale}
onStartCalibration={handleStartScaleCalibration}
onCancelCalibration={handleCancelScaleCalibration}
isCalibrationActive={isScaleCalibrationActive}
/>
),
},
]
: []),
{
id: "viewer-rotate-left",
icon: <LocalIcon icon="rotate-left" width="1rem" height="1rem" />,
@@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons(
formFillLabel,
isFormFillActive,
rulerLabel,
rulerSettingsLabel,
isRulerActive,
setIsRulerActive,
handleStartScaleCalibration,
handleCancelScaleCalibration,
handleApplyRulerScale,
handleResetRulerScale,
customScale,
isScaleCalibrationActive,
readAloudLabel,
readAloudSpeedLabel,
isReadingAloud,
@@ -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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
[triggerImmediatePanInternal],
);
const triggerImmediateRotationUpdate = useCallback(
(rotation: number) => {
triggerImmediateRotationInternal(rotation);
},
[triggerImmediateRotationInternal],
);
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
@@ -638,10 +653,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
registerImmediatePanUpdate,
registerImmediateRotationUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
triggerImmediateSpreadUpdate,
triggerImmediatePanUpdate,
triggerImmediateRotationUpdate,
// Actions
scrollActions,
@@ -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<RulerOverlayHandle | null>;
}
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<PageMeasureScales | null>(null);
const [customScale, setCustomScale] = useState<MeasureScale | null>(null);
const [isScaleCalibrationActive, setIsScaleCalibrationActive] =
useState(false);
const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] =
useState<ScaleCalibrationMeasurement | null>(null);
const [scalesByFileId, setScalesByFileId] = useState<
Map<string, MeasureScale | null>
>(new Map());
const [measurementsByFileId, setMeasurementsByFileId] = useState<
Map<string, Measurement[]>
>(new Map());
const restoredFileKeyRef = useRef<string | null>(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,
};
}
@@ -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,
);
}
}
@@ -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<number, PageScaleInfo>;
@@ -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",
);
});
});
});
@@ -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<string, unknown>;
// 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<string, unknown> {
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<string, unknown>;
} 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<string, unknown> = {
...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<string, unknown> = { [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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
// 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,
};
}
@@ -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<PageMeasureScales | null> {
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;
}
}