ruler support (#5758)

# Description of Changes

<img width="266" height="228" alt="image"
src="https://github.com/user-attachments/assets/bcc6ec11-bd9e-4b83-a081-62149dd92f2a"
/>

<img width="882" height="335" alt="image"
src="https://github.com/user-attachments/assets/b86dbf13-6bcf-4b28-81a6-8c405358a58e"
/>

<img width="1050" height="399" alt="image"
src="https://github.com/user-attachments/assets/6a4468ed-d0f9-44ab-978a-c640d490da8b"
/>

on hover
<img width="380" height="196" alt="image"
src="https://github.com/user-attachments/assets/ba3755b3-4823-48dc-b6aa-3a0f9b0517a3"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] 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)
- [ ] I have performed a self-review of my own code
- [ ] 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)
- [ ] 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)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-02-19 15:58:03 +00:00
committed by GitHub
parent 115a24b16d
commit 8725ba66bb
4 changed files with 871 additions and 2 deletions
@@ -19,10 +19,87 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
// ─── 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;
const rObj = measureObj.lookup(PDFName.of('R'));
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
? rObj.decodeText() : '';
// D = distance array, X = x-axis fallback
let fmtArray = measureObj.lookup(PDFName.of('D'));
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.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 {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
@@ -688,8 +765,20 @@ const EmbedPdfViewerContent = ({
};
}, [applyChanges, setApplyChanges]);
// Ruler / measurement tool state
const [isRulerActive, setIsRulerActive] = useState(false);
const [pageMeasureScales, setPageMeasureScales] = useState<PageMeasureScales | 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]);
// Register viewer right-rail buttons
useViewerRightRailButtons();
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
// Auto-fetch form fields when a PDF is loaded in the viewer.
// In normal viewer mode, this uses pdf-lib (frontend-only).
@@ -819,6 +908,11 @@ const EmbedPdfViewerContent = ({
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
<RulerOverlay
containerRef={pdfContainerRef}
isActive={isRulerActive}
pageMeasureScales={pageMeasureScales}
/>
</Box>
</>
)}