, a direct child of (see
+ // public/index.html + src/index.jsx). This renders a real component into
+ // that real structure, then starts a real print, and checks the selector
+ // against what is actually in the document — the one check that catches
+ // "the print root ended up nested and the whole app printed instead".
+ const appRoot = document.createElement('div');
+ appRoot.id = 'root';
+ document.body.appendChild(appRoot);
+ const appReactRoot = createRoot(appRoot);
+ act(() => appReactRoot.render(
editor
));
+
+ window.print = vi.fn();
+ let finished;
+ await act(async () => {
+ finished = printA4Report(
+ { pageSetup: PAGE_SETUP, elements: [makeElement()] }, {}
+ );
+ });
+
+ const hidden = [...document.body.children]
+ .filter(n => n.matches('body > *:not(.print-root)'));
+ const shown = [...document.body.children]
+ .filter(n => n.matches('.print-root'));
+
+ // Exactly one print root, and it is NOT among the hidden nodes.
+ expect(shown).toHaveLength(1);
+ expect(shown[0].querySelector('.a4-print-el')).not.toBeNull();
+ expect(hidden).not.toContain(shown[0]);
+ // The live editor tree IS hidden by the rule.
+ expect(hidden).toContain(appRoot);
+
+ await act(async () => {
+ window.dispatchEvent(new Event('afterprint'));
+ await finished;
+ });
+
+ act(() => appReactRoot.unmount());
+ appRoot.remove();
+ });
+
+ test('the print container is a DIRECT child of body and carries .print-root', () => {
+ // print.css hides everything with `body > *:not(.print-root)`, a CHILD
+ // combinator. If the class sat only on PrintDocument's inner div, the
+ // appended container would match the hiding rule and the print would be
+ // blank. This asserts the structural contract that rule depends on.
+ const appRoot = document.createElement('div');
+ appRoot.id = 'root';
+ document.body.appendChild(appRoot);
+
+ const container = document.createElement('div');
+ container.className = 'print-root';
+ document.body.appendChild(container);
+
+ expect(container.parentElement).toBe(document.body);
+ expect(container.matches('body > *:not(.print-root)')).toBe(false);
+ // ...and the app's own root, a sibling, IS hidden by it.
+ expect(appRoot.matches('body > *:not(.print-root)')).toBe(true);
+
+ appRoot.remove();
+ container.remove();
+ });
+
+ test('print.css really contains the rules the tests assert against', () => {
+ // The test runner stubs CSS imports to an empty module, so the selector tests
+ // above would still pass if print.css were deleted. Read the file itself so
+ // the shipped stylesheet is what is being verified.
+ const css = fs.readFileSync(
+ path.join(__dirname, '..', 'print', 'print.css'), 'utf8'
+ );
+
+ expect(css).toMatch(/@page\s*\{[^}]*size:\s*A4 portrait/);
+ expect(css).toMatch(/@page\s*\{[^}]*margin:\s*0/);
+ expect(css).toContain('body > *:not(.print-root)');
+ expect(css).toMatch(/\.a4-print-page\s*\{[^}]*width:\s*210mm/);
+ expect(css).toMatch(/\.a4-print-page\s*\{[^}]*height:\s*297mm/);
+ expect(css).toMatch(/\.a4-print-page--landscape\s*\{[^}]*width:\s*297mm/);
+ expect(css).toMatch(/\.a4-print-el\s*\{[^}]*position:\s*absolute/);
+ // Must NOT style .a4-page — A4Canvas.css owns that name for the editor.
+ expect(css).not.toMatch(/^\.a4-page[\s{,]/m);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// printReport() — mounting and, critically, teardown
+// ---------------------------------------------------------------------------
+
+describe('printA4Report', () => {
+ let printSpy;
+ const originalPrint = window.print;
+
+ beforeEach(() => {
+ printSpy = vi.fn();
+ window.print = printSpy;
+ });
+
+ afterEach(() => {
+ window.print = originalPrint;
+ document.getElementById('a4-print-landscape-page-rule')?.remove();
+ });
+
+ const report = () => ({
+ name: 'T', kind: 'a4', pageSetup: PAGE_SETUP,
+ elements: [makeElement({ content: 'АА1234ВВ' })]
+ });
+
+ /**
+ * Fire afterprint and let printA4Report's teardown complete. Cleanup is
+ * deferred through a setTimeout(0) (React forbids unmounting from inside the
+ * event that triggered it), so the timer has to be flushed before asserting.
+ */
+ const finishPrint = async (pending) => {
+ await act(async () => {
+ window.dispatchEvent(new Event('afterprint'));
+ await pending;
+ });
+ };
+
+ test('mounts a .print-root on body, calls window.print, then removes it', async () => {
+ const before = document.body.innerHTML;
+
+ let finished;
+ await act(async () => { finished = printA4Report(report(), {}); });
+
+ // Mounted and rendered before print() was called.
+ const mounted = document.querySelector('.print-root');
+ expect(mounted).not.toBeNull();
+ expect(mounted.parentElement).toBe(document.body);
+ expect(mounted.querySelector('.a4-print-el').style.left).toBe('20mm');
+ expect(printSpy).toHaveBeenCalledTimes(1);
+
+ await finishPrint(finished);
+
+ // Teardown: the editor's DOM is exactly as it was.
+ expect(document.querySelector('.print-root')).toBeNull();
+ expect(document.body.innerHTML).toBe(before);
+ });
+
+ test('accepts the API shape (page_setup) as well as the editor shape', async () => {
+ let finished;
+ await act(async () => {
+ finished = printA4Report(
+ { elements: [makeElement()], page_setup: { ...PAGE_SETUP, orientation: 'landscape' } },
+ {}
+ );
+ });
+
+ expect(document.querySelector('.a4-print-page--landscape')).not.toBeNull();
+ // @page size cannot come from a class, so a rule is injected for landscape.
+ expect(document.getElementById('a4-print-landscape-page-rule')).not.toBeNull();
+
+ await finishPrint(finished);
+
+ expect(document.getElementById('a4-print-landscape-page-rule')).toBeNull();
+ });
+
+ test('portrait does not inject the landscape @page rule', async () => {
+ let finished;
+ await act(async () => { finished = printA4Report(report(), {}); });
+ expect(document.getElementById('a4-print-landscape-page-rule')).toBeNull();
+ await finishPrint(finished);
+ });
+
+ test('repeated prints do not accumulate containers', async () => {
+ for (let i = 0; i < 3; i++) {
+ let finished;
+ await act(async () => { finished = printA4Report(report(), {}); });
+ await finishPrint(finished);
+ // Never more than one at a time, and none left behind.
+ expect(document.querySelectorAll('.print-root')).toHaveLength(0);
+ }
+ });
+
+ test('a duplicate afterprint does not double-unmount', async () => {
+ let finished;
+ await act(async () => { finished = printA4Report(report(), {}); });
+ await act(async () => {
+ window.dispatchEvent(new Event('afterprint'));
+ window.dispatchEvent(new Event('afterprint'));
+ await finished;
+ });
+ expect(document.querySelectorAll('.print-root')).toHaveLength(0);
+ });
+
+ test('a throwing window.print still tears the container down', async () => {
+ // Otherwise a blocked dialog leaves hidden markup stuck in .
+ window.print = () => { throw new Error('blocked'); };
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ await act(async () => { await printA4Report(report(), {}); });
+
+ expect(document.querySelectorAll('.print-root')).toHaveLength(0);
+ err.mockRestore();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The calibration fixture — the numbers the user will measure
+// ---------------------------------------------------------------------------
+
+describe('calibration report (ruler-test fixture)', () => {
+ test('the measured line is at x=20, y=50, width=170, 1mm thick', () => {
+ const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
+ const line = pages[0].find(p => p.el.id === 'a4-cal-line');
+
+ expect(line.x).toBe(20);
+ expect(line.y).toBe(50);
+ expect(line.el.width).toBe(170);
+ // Phase 5: a REAL a4-hline. Its thickness, not a height, is what the ruler
+ // measures — and it is a different render path from every boxed type.
+ expect(line.el.type).toBe('a4-hline');
+ expect(line.el.thickness).toBe(1);
+ });
+
+ test('the Cyrillic sample is at x=20, y=100 and survives layout', () => {
+ const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
+ const text = pages[0].find(p => p.el.id === 'a4-cal-text');
+
+ expect(text.x).toBe(20);
+ expect(text.y).toBe(100);
+ expect(text.text).toBe('АА1234ВВ');
+ });
+
+ test('crosshairs are centred on the four specified corners', () => {
+ const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
+ const at = (id) => pages[0].find(p => p.el.id === id);
+
+ // Each crosshair is a 10mm horizontal arm and a 10mm vertical arm whose
+ // centre lines cross at the named point.
+ [['tl', 10, 10], ['tr', 190, 10], ['bl', 10, 287], ['br', 190, 287]]
+ .forEach(([corner, cx, cy]) => {
+ const h = at(`a4-cal-${corner}-h`);
+ const v = at(`a4-cal-${corner}-v`);
+ expect(h.x).toBe(cx - 5);
+ expect(h.y).toBe(cy);
+ expect(h.el.type).toBe('a4-hline');
+ expect(h.el.width).toBe(10);
+ expect(v.x).toBe(cx);
+ expect(v.y).toBe(cy - 5);
+ expect(v.el.type).toBe('a4-vline');
+ expect(v.el.height).toBe(10);
+ });
+ });
+
+ test('every calibration element fits inside the A4 page box', () => {
+ const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
+ pages[0].forEach(({ el, x, y }) => {
+ // Via getA4Bounds, not el.width/el.height: a line has only the dimension
+ // it runs along, so reading the raw props would compare against undefined
+ // and pass vacuously.
+ const b = getA4Bounds(el);
+ expect(x).toBeGreaterThanOrEqual(0);
+ expect(y).toBeGreaterThanOrEqual(0);
+ expect(b.right).toBeLessThanOrEqual(210);
+ expect(b.bottom).toBeLessThanOrEqual(297);
+ });
+ });
+
+ test('the marks render as mm boxes in the print DOM', () => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
+
+ act(() => root.render(
));
+
+ const line = [...container.querySelectorAll('.a4-calibration-mark')]
+ .find(n => n.style.width === '170mm');
+ expect(line).toBeTruthy();
+ expect(line.style.left).toBe('20mm');
+ expect(line.style.top).toBe('50mm');
+ expect(line.style.height).toBe('1mm');
+
+ act(() => root.unmount());
+ container.remove();
+ });
+});
diff --git a/frontend/src/components/ReportEditor/a4/a4BandRenderer.js b/frontend/src/components/ReportEditor/a4/a4BandRenderer.js
new file mode 100644
index 0000000..0991331
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/a4BandRenderer.js
@@ -0,0 +1,230 @@
+/**
+ * Band instancing for the A4 designer — millimetre floats, PURE (no React).
+ *
+ * Ported from `utils/bandRenderer.js` (the matrix editor's engine), which is on
+ * the protected list and is NOT modified. Three differences, all forced by the
+ * change of unit:
+ *
+ * 1. Arithmetic is in mm floats, not integer character rows.
+ * 2. The inclusive `-1`/`+1` offsets are GONE. The matrix version tracks
+ * `band.y + band.height - 1` as a band's end because row 10 of a 3-row band
+ * starting at 8 is the LAST occupied row. Millimetres are continuous: a 3mm
+ * band at y=8 occupies [8, 11) and ends at 11. Keeping the -1 would shave a
+ * millimetre off every band and open a 1mm hole between consecutive bands.
+ * 3. `calculateTotalContentHeight`'s hardcoded 66 (rows) becomes the page's
+ * content height in mm, passed in by the caller.
+ *
+ * Everything else — including the recursive `parentBandId` nesting — ports
+ * structurally unchanged, because it was already unit-agnostic.
+ */
+
+/** Resolve a dotted path in an object (e.g. 'contact.phone'). */
+function resolveNestedPath(obj, path) {
+ if (!path || !obj) return undefined;
+
+ const parts = path.split('.');
+ let current = obj;
+
+ for (const part of parts) {
+ if (current && typeof current === 'object' && part in current) {
+ current = current[part];
+ } else {
+ return undefined;
+ }
+ }
+
+ return current;
+}
+
+/**
+ * The array a band iterates over.
+ *
+ * @param {Object} reportData full report data
+ * @param {string} dataSource key/path of the array
+ * @param {Object} parentBandData current row of the enclosing band, if nested
+ * @returns {Array}
+ */
+export function getBandDataArray(reportData, dataSource, parentBandData = null) {
+ if (!dataSource) return [];
+
+ // Inside a parent band the source is resolved against that band's CURRENT row,
+ // which is what makes a subdetail iterate per-parent rather than globally.
+ if (parentBandData) {
+ const data = resolveNestedPath(parentBandData, dataSource);
+ return Array.isArray(data) ? data : [];
+ }
+
+ if (!reportData) return [];
+ const data = reportData[dataSource];
+ return Array.isArray(data) ? data : [];
+}
+
+/**
+ * Expand bands into positioned instances, one per data row.
+ *
+ * @param {Array} bands the `a4-band` elements
+ * @param {Object} reportData full report data
+ * @param {Array} allElements every element (children are looked up by id)
+ * @param {Object} parentBandData current row of the enclosing band, if nested
+ * @param {number} yOffset laid-out Y of the parent instance (nested only)
+ * @param {number} parentDesignY design Y of the parent band (nested only)
+ * @returns {Array<{bandId, instanceIndex, y, bandX, height, data, childElements,
+ * originalBandY, bandType}>} in flow order
+ */
+export function calculateBandInstances(
+ bands,
+ reportData,
+ allElements,
+ parentBandData = null,
+ yOffset = null,
+ parentDesignY = null
+) {
+ const instances = [];
+
+ const currentLevelBands = bands.filter(band =>
+ parentBandData ? band.parentBandId === parentBandData.parentBandId : !band.parentBandId
+ );
+
+ const sortedBands = [...currentLevelBands].sort((a, b) => a.y - b.y);
+
+ // Where the previous band ended, in DESIGN space and in LAID-OUT space. The
+ // difference between the two is the accumulated growth from data iteration.
+ let previousBandDesignEnd = null;
+ let previousBandPreviewEnd = null;
+ let currentY = 0;
+
+ for (const band of sortedBands) {
+ if (previousBandDesignEnd === null) {
+ if (yOffset !== null && parentDesignY !== null) {
+ // Nested: keep the designed offset from the parent's top.
+ currentY = yOffset + (band.y - parentDesignY);
+ } else {
+ currentY = band.y;
+ }
+ } else {
+ // Preserve the designed gap between bands, after the previous band has
+ // grown by however many rows it rendered.
+ const designGap = band.y - previousBandDesignEnd;
+ currentY = previousBandPreviewEnd + designGap;
+ }
+
+ const dataArray = getBandDataArray(reportData, band.dataSource, parentBandData);
+ const childElements = allElements.filter(
+ el => band.children && band.children.includes(el.id)
+ );
+ const nestedBands = bands.filter(b => b.parentBandId === band.id);
+
+ const emit = (data, instanceIndex) => {
+ instances.push({
+ bandId: band.id,
+ instanceIndex,
+ y: currentY,
+ // The band's left edge, so layout() can turn a child's band-relative x
+ // into a page x. Children render inside the band div on the canvas, so
+ // CSS supplies this offset there for free.
+ bandX: band.x || 0,
+ height: band.height,
+ data,
+ childElements,
+ originalBandY: band.y,
+ bandType: band.bandType
+ });
+
+ if (nestedBands.length > 0) {
+ const nestedInstances = calculateBandInstances(
+ nestedBands,
+ reportData,
+ allElements,
+ // A static band (no data) passes no row down, so a nested band under it
+ // resolves its own source against reportData instead.
+ data === null ? null : { ...data, parentBandId: band.id },
+ currentY,
+ band.y
+ );
+ instances.push(...nestedInstances);
+ currentY = nestedInstances.reduce(
+ (max, ni) => Math.max(max, ni.y + ni.height),
+ currentY + band.height
+ );
+ } else {
+ currentY += band.height;
+ }
+ };
+
+ if (dataArray.length === 0) {
+ // No data — render once with null. This is how header/footer/summary bands
+ // and any unbound band get exactly one instance.
+ emit(null, 0);
+ } else {
+ dataArray.forEach((item, index) => emit(item, index));
+ }
+
+ previousBandDesignEnd = band.y + band.height;
+ previousBandPreviewEnd = currentY;
+ }
+
+ return instances;
+}
+
+/**
+ * Total laid-out height in mm, before pagination splits it into pages.
+ *
+ * @param {number} minHeightMm the page's content height — the matrix version
+ * hardcoded 66 rows here, which is meaningless in mm.
+ */
+export function calculateTotalContentHeight(bands, reportData, allElements, minHeightMm) {
+ if (!bands || bands.length === 0) return minHeightMm;
+
+ const instances = calculateBandInstances(bands, reportData, allElements);
+ if (instances.length === 0) return minHeightMm;
+
+ return instances.reduce((max, i) => Math.max(max, i.y + i.height), minHeightMm);
+}
+
+/**
+ * Arrays available as a band's dataSource — drives the inspector dropdown.
+ *
+ * With a parent band, the candidates come from a SAMPLE ROW of the parent's
+ * array rather than from the top level, because a nested band's source is
+ * resolved against the parent's current row.
+ */
+export function getAvailableArrays(reportData, parentBand = null) {
+ const arrays = [];
+
+ if (parentBand && parentBand.dataSource) {
+ const parentDataArray = reportData?.[parentBand.dataSource];
+ if (Array.isArray(parentDataArray) && parentDataArray.length > 0) {
+ findArraysInObject(parentDataArray[0], arrays);
+ return arrays;
+ }
+ return [];
+ }
+
+ if (reportData && typeof reportData === 'object') {
+ for (const key of Object.keys(reportData)) {
+ if (Array.isArray(reportData[key])) {
+ arrays.push({ key, path: key });
+ }
+ }
+ }
+
+ return arrays;
+}
+
+/** Collect arrays inside an object, recursing through plain objects only. */
+function findArraysInObject(obj, arrays, prefix = '') {
+ if (!obj || typeof obj !== 'object') return;
+
+ for (const key of Object.keys(obj)) {
+ const value = obj[key];
+ const path = prefix ? `${prefix}.${key}` : key;
+
+ if (Array.isArray(value)) {
+ arrays.push({ key, path });
+ // Deliberately no recursion INTO arrays — only immediate arrays are
+ // usable as a data source.
+ } else if (typeof value === 'object' && value !== null) {
+ findArraysInObject(value, arrays, path);
+ }
+ }
+}
diff --git a/frontend/src/components/ReportEditor/a4/a4Elements.css b/frontend/src/components/ReportEditor/a4/a4Elements.css
new file mode 100644
index 0000000..363b0c1
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/a4Elements.css
@@ -0,0 +1,234 @@
+/* A4 designer element styles — CSS borders and real fonts, no box characters. */
+
+.a4-text-field {
+ position: absolute;
+ box-sizing: border-box;
+ display: flex;
+ align-items: flex-start;
+ overflow: hidden;
+ cursor: move;
+ outline: 1px dashed transparent;
+ line-height: 1.2;
+ user-select: none;
+}
+
+.a4-text-field:hover {
+ outline-color: #b9c2f0;
+}
+
+.a4-text-field.selected {
+ outline: 1px solid #667eea;
+}
+
+.a4-text-field.dragging {
+ opacity: 0.75;
+}
+
+.a4-text-field-content {
+ width: 100%;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.a4-text-field-input {
+ width: 100%;
+ border: 1px solid #667eea;
+ padding: 0;
+ margin: 0;
+ background: #fff;
+ font: inherit;
+ color: inherit;
+ text-align: inherit;
+ box-sizing: border-box;
+}
+
+.a4-text-field-input:focus {
+ outline: none;
+}
+
+/* Data-bound and expression fields share the text box's behaviour. */
+.a4-db-text-field,
+.a4-expr-field {
+ position: absolute;
+ box-sizing: border-box;
+ display: flex;
+ align-items: flex-start;
+ overflow: hidden;
+ cursor: move;
+ outline: 1px dashed transparent;
+ line-height: 1.2;
+ user-select: none;
+}
+
+.a4-db-text-field:hover,
+.a4-expr-field:hover {
+ outline-color: #b9c2f0;
+}
+
+.a4-db-text-field.selected,
+.a4-expr-field.selected {
+ outline: 1px solid #667eea;
+}
+
+.a4-db-text-field.dragging,
+.a4-expr-field.dragging {
+ opacity: 0.75;
+}
+
+/* An unbound field is invisible on a blank page otherwise — this is a DESIGN
+ affordance only; nothing in the print path reads this class. */
+.a4-db-text-field.unresolved,
+.a4-expr-field.unresolved {
+ outline: 1px dashed #e08a3c;
+ background: rgba(224, 138, 60, 0.06);
+}
+
+.a4-field-content {
+ width: 100%;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+/* Rectangle — a real CSS border, sized in mm. */
+.a4-rect {
+ position: absolute;
+ box-sizing: border-box;
+ cursor: move;
+}
+
+.a4-rect.selected {
+ outline: 1px solid #667eea;
+ outline-offset: 1px;
+}
+
+.a4-rect.dragging {
+ opacity: 0.75;
+}
+
+/* Lines. The visible box IS the line, so selection must never be drawn as a
+ border (that would change the apparent thickness) — an offset outline keeps
+ the measured geometry honest. */
+.a4-hline,
+.a4-vline {
+ position: absolute;
+ box-sizing: border-box;
+ cursor: move;
+}
+
+.a4-hline.selected,
+.a4-vline.selected {
+ outline: 1px solid #667eea;
+ outline-offset: 2px;
+}
+
+.a4-hline.dragging,
+.a4-vline.dragging {
+ opacity: 0.75;
+}
+
+/* A 0.3mm line is about 1px on screen and effectively unclickable. This
+ invisible pseudo-element widens the GRAB area to ~9px across without
+ touching the painted line's millimetres. */
+.a4-hline::before,
+.a4-vline::before {
+ content: '';
+ position: absolute;
+ background: transparent;
+}
+
+.a4-hline::before {
+ left: 0;
+ right: 0;
+ top: -4px;
+ bottom: -4px;
+}
+
+.a4-vline::before {
+ top: 0;
+ bottom: 0;
+ left: -4px;
+ right: -4px;
+}
+
+/* Calibration marks (the ruler-test fixture). Now that a4-hline/a4-vline
+ exist, the calibration report uses REAL lines; this class survives only to
+ mark them visually distinct in the editor. Same rule in print.css. */
+.a4-calibration-mark {
+ outline: none !important;
+}
+
+/* Resize handles */
+.a4-resize-handle {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: #fff;
+ border: 1px solid #667eea;
+ border-radius: 1px;
+ z-index: 20;
+}
+
+.a4-resize-handle:hover {
+ background: #667eea;
+}
+
+/* Transitions are disabled globally while useElementDrag is active */
+body.dragging-active .a4-text-field,
+body.dragging-active .a4-db-text-field,
+body.dragging-active .a4-expr-field,
+body.dragging-active .a4-rect,
+body.dragging-active .a4-hline,
+body.dragging-active .a4-vline {
+ transition: none !important;
+}
+
+/* Band container — a DESIGN-TIME affordance only. layout() never emits the band
+ itself, so none of this reaches paper; only its children print. */
+/* A horizontal STRIP, not a box: top/bottom rules only, as in the matrix
+ editor. A band spans the content width and moves only up and down, so side
+ borders would suggest a left/right edge the user can take hold of. */
+.a4-band {
+ position: absolute;
+ box-sizing: border-box;
+ border: 1px dashed #7a8bb8;
+ border-left: none;
+ border-right: none;
+ background: rgba(122, 139, 184, 0.05);
+ cursor: ns-resize;
+}
+
+.a4-band.selected {
+ border-color: #667eea;
+ background: rgba(102, 126, 234, 0.1);
+}
+
+.a4-band.dragging {
+ opacity: 0.75;
+}
+
+/* Colour-code the band types so a stack of them is readable at a glance. */
+.a4-band--header { border-color: #3c8ae0; }
+.a4-band--footer { border-color: #3c8ae0; }
+.a4-band--detail { border-color: #4caf7d; }
+.a4-band--subdetail { border-color: #9c8ae0; }
+.a4-band--summary { border-color: #e0913c; }
+
+.a4-band-caption {
+ position: absolute;
+ top: 0;
+ left: 0;
+ padding: 0 4px;
+ font-family: system-ui, sans-serif;
+ font-size: 9px;
+ line-height: 12px;
+ color: #40506f;
+ background: rgba(255, 255, 255, 0.85);
+ white-space: nowrap;
+ pointer-events: none;
+ user-select: none;
+ z-index: 2;
+}
+
+body.dragging-active .a4-band {
+ transition: none !important;
+}
diff --git a/frontend/src/components/ReportEditor/a4/calibrationReport.js b/frontend/src/components/ReportEditor/a4/calibrationReport.js
new file mode 100644
index 0000000..1bfed04
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/calibrationReport.js
@@ -0,0 +1,127 @@
+/**
+ * The calibration report — the fixture for the plan's §8 "ruler test".
+ *
+ * This is the acceptance test for millimetre correctness, and it runs on PAPER,
+ * with a steel rule. Everything in the A4 designer is built on the assumption
+ * that a stored millimetre prints as a physical millimetre; this report is what
+ * proves it.
+ *
+ * PHASE 5 CHANGE — the marks are now REAL LINES.
+ * Phase 4 had only one element type, `a4-text`, so each "line" was a text box
+ * with empty content whose painted background stood in for the line. That
+ * measured the shared coordinate pipeline, but it could not measure the hline
+ * render path, because there was none. Now that `a4-hline` and `a4-vline`
+ * exist, the fixture uses them: the ruler test now exercises exactly the
+ * element type a real report will use for a rule, including its own
+ * `thickness` -> CSS mm height conversion, which is a separate code path from
+ * the `height` every other type uses.
+ *
+ * The geometry is unchanged from Phase 4, so a sheet printed before and after
+ * this change must measure identically. If it does not, the hline render path
+ * is what differs — which is precisely what this change makes measurable.
+ *
+ * The marks are 1mm thick, so "measure the line's top edge" is unambiguous.
+ */
+
+const MARK_THICKNESS_MM = 1;
+
+const hline = (id, x, y, width) => ({
+ id,
+ type: 'a4-hline',
+ x,
+ y,
+ width,
+ thickness: MARK_THICKNESS_MM,
+ color: '#000000',
+ className: 'a4-calibration-mark'
+});
+
+const vline = (id, x, y, height) => ({
+ id,
+ type: 'a4-vline',
+ x,
+ y,
+ height,
+ thickness: MARK_THICKNESS_MM,
+ color: '#000000',
+ className: 'a4-calibration-mark'
+});
+
+/** Crosshair at (cx, cy): a 10mm horizontal and a 10mm vertical arm, 1mm thick. */
+const crosshair = (id, cx, cy) => [
+ hline(`${id}-h`, cx - 5, cy, 10),
+ vline(`${id}-v`, cx, cy - 5, 10)
+];
+
+const label = (id, y, content, bold = false) => ({
+ id,
+ type: 'a4-text',
+ x: 20,
+ y,
+ width: 170,
+ height: 6,
+ content,
+ fontFamily: 'Arial',
+ fontSize: 9,
+ bold,
+ italic: false,
+ align: 'left',
+ color: '#000000'
+});
+
+export const CALIBRATION_PAGE_SETUP = {
+ page: 'A4',
+ orientation: 'portrait',
+ margins: { top: 15, right: 15, bottom: 15, left: 20 },
+ defaultFont: 'Arial',
+ defaultFontSize: 10
+};
+
+export function buildCalibrationReport() {
+ return {
+ name: 'A4 calibration (ruler test)',
+ kind: 'a4',
+ pageSetup: { ...CALIBRATION_PAGE_SETUP, margins: { ...CALIBRATION_PAGE_SETUP.margins } },
+ apiEndpoint: '',
+ elements: [
+ // THE MEASUREMENT. Left end at x=20mm, top edge at y=50mm, length 170mm.
+ hline('a4-cal-line', 20, 50, 170),
+
+ // Cyrillic, to prove the font really renders it (and is not a fallback
+ // box). 14pt so it is legible enough to compare against Word on paper.
+ {
+ id: 'a4-cal-text',
+ type: 'a4-text',
+ x: 20,
+ y: 100,
+ width: 80,
+ height: 8,
+ content: 'АА1234ВВ',
+ fontFamily: 'Arial',
+ fontSize: 14,
+ bold: false,
+ italic: false,
+ align: 'left',
+ color: '#000000'
+ },
+
+ // Corner crosshairs — a consistent offset in all four proves a page-setup
+ // or unprintable-area problem; an offset in only one or two proves skew.
+ ...crosshair('a4-cal-tl', 10, 10),
+ ...crosshair('a4-cal-tr', 190, 10),
+ ...crosshair('a4-cal-bl', 10, 287),
+ ...crosshair('a4-cal-br', 190, 287),
+
+ // Labels so the printed sheet documents its own expected values — the
+ // person with the ruler should not have to read this file.
+ label('a4-cal-label-line', 40,
+ 'Line below: left edge = 20mm, top edge = 50mm, length = 170mm'),
+ label('a4-cal-label-text', 92,
+ 'Cyrillic below at x=20mm, y=100mm. Crosshair centres: 10/10, 190/10, 10/287, 190/287'),
+ label('a4-cal-label-scale', 120,
+ 'Print at 100% / Actual size, with Headers and footers UNTICKED.', true)
+ ]
+ };
+}
+
+export default buildCalibrationReport;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4Band.jsx b/frontend/src/components/ReportEditor/a4/elements/A4Band.jsx
new file mode 100644
index 0000000..b4ad5d8
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4Band.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import A4ResizeHandles, { VERTICAL_HANDLES } from './A4ResizeHandles';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { mmToPx } from '../units';
+import '../a4Elements.css';
+
+/**
+ * Band container for the A4 designer.
+ *
+ * DESIGN-TIME ONLY. The dashed border and the caption are affordances for the
+ * editor; `layout()` never emits the band itself, so nothing here reaches paper.
+ * What prints is the band's CHILDREN, once per data row.
+ *
+ * Two deliberate constraints:
+ *
+ * - X and WIDTH are not user-set. A band spans the page's content width, which
+ * is derived from page_setup's margins. Offering a width control would let a
+ * user set one that the next margin change silently contradicts.
+ * - Only the VERTICAL resize handles are offered, for the same reason — a west
+ * or east handle would write a width the layout engine recomputes anyway.
+ *
+ * Children are rendered INSIDE this element, so their stored `y` is relative to
+ * the band (matching the matrix editor) and CSS positioning does the offset for
+ * free rather than every child needing to know its parent's position.
+ */
+function A4Band({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode,
+ children
+}) {
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete,
+ toolMode
+ });
+
+ const handleMouseDown = (e) => {
+ // A mousedown that started on a CHILD must not also start a band drag, or
+ // moving one field inside a band would drag the whole band with it.
+ if (e.target !== e.currentTarget) return;
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${mmToPx(element.width, zoom)}px`,
+ height: `${mmToPx(element.height, zoom)}px`
+ };
+
+ const className = [
+ 'a4-band',
+ `a4-band--${element.bandType || 'detail'}`,
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : ''
+ ].filter(Boolean).join(' ');
+
+ const caption = [
+ element.caption,
+ element.bandType || 'detail',
+ element.dataSource ? `→ ${element.dataSource}` : null
+ ].filter(Boolean).join(' · ');
+
+ return (
+
+
{caption}
+ {children}
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default A4Band;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4DBTextField.jsx b/frontend/src/components/ReportEditor/a4/elements/A4DBTextField.jsx
new file mode 100644
index 0000000..8489aff
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4DBTextField.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import A4ResizeHandles from './A4ResizeHandles';
+import { useReportData } from '../../DataContext';
+import { resolveDBTextValue } from '../../utils/dataResolver';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { formatValue } from '../formatValue';
+import { mmToPx, ptToPx } from '../units';
+import { getFontStack } from '../fonts';
+import '../a4Elements.css';
+
+/**
+ * Data-bound field for the A4 designer.
+ *
+ * Resolution logic follows the matrix `DBTextField.jsx`: design mode shows the
+ * binding as a `{object.field}` placeholder so an unbound field is visible on a
+ * blank canvas, preview mode shows the resolved and formatted value. The
+ * styling is mm-native instead of charWidth/charHeight, and it uses the shared
+ * `a4/formatValue.js` rather than a third local copy of that function.
+ *
+ * Bands are Phase 6, so there is no parentBandId here yet: `resolveDBTextValue`
+ * is called with the top-level reportData only.
+ */
+function A4DBTextField({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode,
+ previewMode = false
+}) {
+ const { reportData } = useReportData();
+
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete,
+ toolMode
+ });
+
+ const handleMouseDown = (e) => {
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const displayContent = previewMode
+ ? formatValue(
+ resolveDBTextValue(reportData, element.objectKey, element.fieldPath),
+ element.dataType || 'general'
+ )
+ : `{${element.objectKey || '?'}.${element.fieldPath || '?'}}`;
+
+ const isUnresolved = !element.objectKey || !element.fieldPath ||
+ (previewMode && !displayContent);
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${mmToPx(element.width, zoom)}px`,
+ height: `${mmToPx(element.height, zoom)}px`,
+ fontFamily: getFontStack(element.fontFamily),
+ fontSize: `${ptToPx(element.fontSize, zoom)}px`,
+ fontWeight: element.bold ? 700 : 400,
+ fontStyle: element.italic ? 'italic' : 'normal',
+ textAlign: element.align || 'left',
+ color: element.color || '#000000'
+ };
+
+ const className = [
+ 'a4-db-text-field',
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : '',
+ isUnresolved ? 'unresolved' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+
{displayContent || '(No Data)'}
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default A4DBTextField;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4ExprField.jsx b/frontend/src/components/ReportEditor/a4/elements/A4ExprField.jsx
new file mode 100644
index 0000000..ab6f9b9
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4ExprField.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import A4ResizeHandles from './A4ResizeHandles';
+import { useReportData } from '../../DataContext';
+import { evaluateExpression } from '../../utils/expressionEvaluator';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { formatValue } from '../formatValue';
+import { makeFieldResolver } from '../layout';
+import { mmToPx, ptToPx } from '../units';
+import { getFontStack } from '../fonts';
+import '../a4Elements.css';
+
+/**
+ * Computed-expression field for the A4 designer.
+ *
+ * Same shape as the matrix `ExpressionField.jsx`: design mode shows the raw
+ * expression source, preview mode shows the evaluated and formatted result.
+ *
+ * The `[object.field]` resolver is IMPORTED from layout.js rather than copied.
+ * That matters beyond tidiness — the screen and the printed page must agree on
+ * what a reference resolves to, and one function is the only way to guarantee
+ * that. It is safe to import because layout.js is pure: no React, no DOM.
+ */
+function A4ExprField({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode,
+ previewMode = false
+}) {
+ const { reportData } = useReportData();
+
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete,
+ toolMode
+ });
+
+ const handleMouseDown = (e) => {
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const displayContent = previewMode
+ ? formatValue(
+ evaluateExpression(element.expression, makeFieldResolver(reportData)),
+ element.dataType || 'general'
+ )
+ : (element.expression || '(expr)');
+
+ const isUnresolved = !element.expression;
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${mmToPx(element.width, zoom)}px`,
+ height: `${mmToPx(element.height, zoom)}px`,
+ fontFamily: getFontStack(element.fontFamily),
+ fontSize: `${ptToPx(element.fontSize, zoom)}px`,
+ fontWeight: element.bold ? 700 : 400,
+ fontStyle: element.italic ? 'italic' : 'normal',
+ textAlign: element.align || 'left',
+ color: element.color || '#000000'
+ };
+
+ const className = [
+ 'a4-expr-field',
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : '',
+ isUnresolved ? 'unresolved' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+
{displayContent || '(No Expr)'}
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default A4ExprField;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4HLine.jsx b/frontend/src/components/ReportEditor/a4/elements/A4HLine.jsx
new file mode 100644
index 0000000..e0920e4
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4HLine.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import A4ResizeHandles, { HORIZONTAL_HANDLES } from './A4ResizeHandles';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { mmToPx } from '../units';
+import '../a4Elements.css';
+
+/**
+ * Horizontal rule for the A4 designer.
+ *
+ * A filled box `width` mm long and `thickness` mm tall — NOT a box-drawing
+ * character, and with NO crossroad/junction detection. Junctions are a
+ * character-grid concept: on a grid two lines meeting must agree on which glyph
+ * occupies the shared cell. Millimetre lines simply overlap, and the browser
+ * paints both.
+ *
+ * Background rather than border-top, so the painted height is exactly the
+ * stored thickness with no box-sizing interaction to reason about.
+ *
+ * Hit target: a 0.3mm line is ~1px on screen, which is unclickable. The CSS
+ * gives the element a transparent padded hit area via a pseudo-element, so the
+ * visible line stays honest while the grab zone is usable.
+ */
+function A4HLine({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode
+}) {
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete,
+ toolMode
+ });
+
+ const handleMouseDown = (e) => {
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${mmToPx(element.width, zoom)}px`,
+ // Floored at 1px so a hairline does not vanish on screen when zoomed out.
+ // The printed thickness comes from PrintDocument's true mm, not from here.
+ height: `${Math.max(1, mmToPx(element.thickness || 0, zoom))}px`,
+ background: element.color || '#000000'
+ };
+
+ const className = [
+ 'a4-hline',
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default A4HLine;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4Rect.jsx b/frontend/src/components/ReportEditor/a4/elements/A4Rect.jsx
new file mode 100644
index 0000000..a077a8f
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4Rect.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import A4ResizeHandles from './A4ResizeHandles';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { mmToPx } from '../units';
+import '../a4Elements.css';
+
+/**
+ * Rectangle / frame for the A4 designer.
+ *
+ * A REAL CSS border, not the matrix editor's box-drawing characters. Those
+ * exist because a dot-matrix printer draws with a character grid; on A4 a
+ * border is a border, and its thickness is a millimetre float.
+ *
+ * Border width is converted through the zoom like every other mm value, but
+ * is floored at 1px on screen: at 50% zoom a 0.3mm border is 0.57px, which
+ * some browsers round away to nothing, making the rect look deleted. Print is
+ * unaffected — PrintDocument emits the true mm.
+ *
+ * `fillColor: null` means no fill (the default), so a rect can frame content
+ * without hiding it. Only an explicit colour paints a background.
+ */
+function A4Rect({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode
+}) {
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete,
+ toolMode
+ });
+
+ const handleMouseDown = (e) => {
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const borderPx = element.borderWidth > 0
+ ? Math.max(1, mmToPx(element.borderWidth, zoom))
+ : 0;
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${mmToPx(element.width, zoom)}px`,
+ height: `${mmToPx(element.height, zoom)}px`,
+ border: borderPx > 0
+ ? `${borderPx}px solid ${element.borderColor || '#000000'}`
+ : 'none',
+ background: element.fillColor || 'transparent'
+ };
+
+ const className = [
+ 'a4-rect',
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default A4Rect;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4ResizeHandles.jsx b/frontend/src/components/ReportEditor/a4/elements/A4ResizeHandles.jsx
new file mode 100644
index 0000000..634c01d
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4ResizeHandles.jsx
@@ -0,0 +1,132 @@
+import React, { useState, useEffect } from 'react';
+import { mmToPx, pxToMm, roundMm } from '../units';
+import '../a4Elements.css';
+
+/**
+ * Resize in millimetres.
+ *
+ * Structural reference: the matrix `ResizeHandles.jsx` — but deltas convert to mm
+ * floats via pxToMm(delta, zoom) + roundMm. There is NO rounding to integer cells.
+ *
+ * The handle SET is a prop because lines have only one resizable dimension: an
+ * hline is `width` + `thickness`, with no `height` at all. Offering it a south
+ * handle would write a `height` the renderer never reads — a silent no-op the
+ * user would read as a broken control. So an hline gets ['w','e'] and a vline
+ * ['n','s'], and the code below only ever touches the axis its handle names.
+ */
+
+const MIN_WIDTH_MM = 5;
+const MIN_HEIGHT_MM = 3;
+
+export const ALL_HANDLES = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'];
+export const HORIZONTAL_HANDLES = ['w', 'e'];
+export const VERTICAL_HANDLES = ['n', 's'];
+
+const CURSORS = {
+ nw: 'nw-resize',
+ n: 'n-resize',
+ ne: 'ne-resize',
+ w: 'w-resize',
+ e: 'e-resize',
+ sw: 'sw-resize',
+ s: 's-resize',
+ se: 'se-resize'
+};
+
+function A4ResizeHandles({ element, onResize, zoom, handles = ALL_HANDLES }) {
+ const [dragData, setDragData] = useState(null);
+
+ const getHandlePosition = (handle) => {
+ // Lines carry only the dimension they run along; the other extent is their
+ // thickness. Same substitution geometry.js makes, for the same reason.
+ const width = mmToPx(
+ element.type === 'a4-vline' ? (element.thickness || 0) : element.width, zoom
+ );
+ const height = mmToPx(
+ element.type === 'a4-hline' ? (element.thickness || 0) : element.height, zoom
+ );
+
+ const positions = {
+ nw: { left: -4, top: -4 },
+ n: { left: width / 2 - 4, top: -4 },
+ ne: { left: width - 4, top: -4 },
+ w: { left: -4, top: height / 2 - 4 },
+ e: { left: width - 4, top: height / 2 - 4 },
+ sw: { left: -4, top: height - 4 },
+ s: { left: width / 2 - 4, top: height - 4 },
+ se: { left: width - 4, top: height - 4 }
+ };
+
+ return positions[handle];
+ };
+
+ const handleMouseDown = (e, handle) => {
+ e.stopPropagation();
+ setDragData({
+ handle,
+ startX: e.clientX,
+ startY: e.clientY,
+ initialElement: { ...element }
+ });
+ };
+
+ useEffect(() => {
+ if (!dragData) return;
+
+ const handleMouseMove = (e) => {
+ const deltaMmX = pxToMm(e.clientX - dragData.startX, zoom);
+ const deltaMmY = pxToMm(e.clientY - dragData.startY, zoom);
+ const initial = dragData.initialElement;
+ const { handle } = dragData;
+
+ const updates = {};
+
+ // Horizontal: west handles move the origin, east handles only grow width
+ if (handle.includes('w')) {
+ const newWidth = Math.max(MIN_WIDTH_MM, initial.width - deltaMmX);
+ updates.x = roundMm(initial.x + (initial.width - newWidth));
+ updates.width = roundMm(newWidth);
+ } else if (handle.includes('e')) {
+ updates.width = roundMm(Math.max(MIN_WIDTH_MM, initial.width + deltaMmX));
+ }
+
+ // Vertical: north handles move the origin, south handles only grow height
+ if (handle.includes('n')) {
+ const newHeight = Math.max(MIN_HEIGHT_MM, initial.height - deltaMmY);
+ updates.y = roundMm(initial.y + (initial.height - newHeight));
+ updates.height = roundMm(newHeight);
+ } else if (handle.includes('s')) {
+ updates.height = roundMm(Math.max(MIN_HEIGHT_MM, initial.height + deltaMmY));
+ }
+
+ if (Object.keys(updates).length > 0) {
+ onResize(updates);
+ }
+ };
+
+ const handleMouseUp = () => setDragData(null);
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [dragData, onResize, zoom]);
+
+ return (
+ <>
+ {handles.map(handle => (
+
handleMouseDown(e, handle)}
+ />
+ ))}
+ >
+ );
+}
+
+export default A4ResizeHandles;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4TextField.jsx b/frontend/src/components/ReportEditor/a4/elements/A4TextField.jsx
new file mode 100644
index 0000000..8b25cfd
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4TextField.jsx
@@ -0,0 +1,150 @@
+import React, { useState, useRef, useEffect } from 'react';
+import A4ResizeHandles from './A4ResizeHandles';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { mmToPx, ptToPx } from '../units';
+import { getFontStack } from '../fonts';
+import '../a4Elements.css';
+
+/**
+ * Static text element for the A4 designer.
+ *
+ * Position/size are mm floats converted to px at render time; font size is pt
+ * rendered as pt * (96/72) * zoom px. Nothing here writes a pixel value back.
+ */
+function A4TextField({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editContent, setEditContent] = useState(element.content);
+ const inputRef = useRef(null);
+
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete: () => {
+ if (!isEditing) onDelete();
+ },
+ toolMode
+ });
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ useEffect(() => {
+ if (!isSelected) {
+ setIsEditing(false);
+ }
+ }, [isSelected]);
+
+ const handleDoubleClick = (e) => {
+ if (toolMode === 'select') {
+ e.stopPropagation();
+ if (!isEditing) {
+ setIsEditing(true);
+ setEditContent(element.content);
+ }
+ }
+ };
+
+ const handleBlur = () => {
+ if (isEditing) {
+ setIsEditing(false);
+ if (editContent !== element.content) {
+ onUpdate({ content: editContent });
+ }
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ // Stop the canvas-level shortcut handler seeing keys typed into this input
+ e.stopPropagation();
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleBlur();
+ } else if (e.key === 'Escape') {
+ setIsEditing(false);
+ setEditContent(element.content);
+ }
+ };
+
+ const handleMouseDown = (e) => {
+ if (isEditing) return;
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${mmToPx(element.width, zoom)}px`,
+ height: `${mmToPx(element.height, zoom)}px`,
+ fontFamily: getFontStack(element.fontFamily),
+ fontSize: `${ptToPx(element.fontSize, zoom)}px`,
+ fontWeight: element.bold ? 700 : 400,
+ fontStyle: element.italic ? 'italic' : 'normal',
+ textAlign: element.align || 'left',
+ color: element.color || '#000000'
+ };
+
+ const className = [
+ 'a4-text-field',
+ element.className || '',
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {isEditing ? (
+
setEditContent(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ onMouseDown={(e) => e.stopPropagation()}
+ />
+ ) : (
+
{element.content}
+ )}
+ {isSingleSelection && !isEditing && (
+
+ )}
+
+ );
+}
+
+export default A4TextField;
diff --git a/frontend/src/components/ReportEditor/a4/elements/A4VLine.jsx b/frontend/src/components/ReportEditor/a4/elements/A4VLine.jsx
new file mode 100644
index 0000000..6d01755
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/elements/A4VLine.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import A4ResizeHandles, { VERTICAL_HANDLES } from './A4ResizeHandles';
+import { useElementDrag } from '../../hooks/useElementDrag';
+import { useElementSelection } from '../../hooks/useElementSelection';
+import { mmToPx } from '../units';
+import '../a4Elements.css';
+
+/**
+ * Vertical rule for the A4 designer. The transpose of A4HLine — see its header
+ * for why this is a filled box with no junction detection.
+ */
+function A4VLine({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ zoom,
+ toolMode
+}) {
+ const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
+ isSelected,
+ onDragStart,
+ onDrag,
+ toolMode
+ });
+
+ const { handleClick } = useElementSelection({
+ isSelected,
+ onSelect,
+ onDelete,
+ toolMode
+ });
+
+ const handleMouseDown = (e) => {
+ onSelect();
+ handleDragMouseDown(e, element.id);
+ };
+
+ const style = {
+ left: `${mmToPx(element.x, zoom)}px`,
+ top: `${mmToPx(element.y, zoom)}px`,
+ width: `${Math.max(1, mmToPx(element.thickness || 0, zoom))}px`,
+ height: `${mmToPx(element.height, zoom)}px`,
+ background: element.color || '#000000'
+ };
+
+ const className = [
+ 'a4-vline',
+ isSelected ? 'selected' : '',
+ isDragging ? 'dragging' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default A4VLine;
diff --git a/frontend/src/components/ReportEditor/a4/fonts.js b/frontend/src/components/ReportEditor/a4/fonts.js
new file mode 100644
index 0000000..c35288f
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/fonts.js
@@ -0,0 +1,24 @@
+/**
+ * Fonts offered by the A4 designer.
+ *
+ * Only Windows-bundled families, all of which cover Cyrillic. Chrome prints with
+ * the fonts installed on the station, so no embedding or substitution is needed.
+ *
+ * The SAME stack string is used on screen and (Phase 4) in print CSS, so WYSIWYG
+ * holds by construction — one font stack, one rendering engine.
+ */
+
+export const FONT_STACKS = {
+ 'Arial': 'Arial, Helvetica, sans-serif',
+ 'Times New Roman': '"Times New Roman", Times, serif',
+ 'Courier New': '"Courier New", Courier, monospace',
+ 'Calibri': 'Calibri, Arial, sans-serif',
+ 'Segoe UI': '"Segoe UI", Tahoma, Arial, sans-serif',
+ 'Tahoma': 'Tahoma, Verdana, Arial, sans-serif',
+ 'Verdana': 'Verdana, Tahoma, Arial, sans-serif'
+};
+
+export const FONT_FAMILIES = Object.keys(FONT_STACKS);
+
+/** CSS font-family stack for a stored family name, falling back to Arial. */
+export const getFontStack = (family) => FONT_STACKS[family] || FONT_STACKS['Arial'];
diff --git a/frontend/src/components/ReportEditor/a4/formatValue.js b/frontend/src/components/ReportEditor/a4/formatValue.js
new file mode 100644
index 0000000..723cd81
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/formatValue.js
@@ -0,0 +1,34 @@
+/**
+ * Value formatting for the A4 designer.
+ *
+ * Copied verbatim from `DBTextField.jsx:11-33` — where the same function is
+ * already duplicated byte-for-byte in `ExpressionField.jsx`. Lifting it here
+ * consolidates an existing duplicate rather than creating a new one, and gives
+ * the pure `layout()` path a formatter that does not import React.
+ */
+
+export function formatValue(raw, dataType) {
+ if (raw === '' || raw === null || raw === undefined) return '';
+ switch (dataType) {
+ case 'number': {
+ const n = Number(raw);
+ return isNaN(n) ? '' : String(n);
+ }
+ case 'date': {
+ const d = new Date(raw);
+ return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
+ }
+ case 'time': {
+ const d = new Date(raw);
+ return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ case 'datetime': {
+ const d = new Date(raw);
+ return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ default:
+ return String(raw);
+ }
+}
+
+export default formatValue;
diff --git a/frontend/src/components/ReportEditor/a4/geometry.js b/frontend/src/components/ReportEditor/a4/geometry.js
new file mode 100644
index 0000000..c029201
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/geometry.js
@@ -0,0 +1,61 @@
+/**
+ * Geometry for the A4 designer — millimetres, EXCLUSIVE bounds.
+ *
+ * NOTE the deliberate difference from the matrix editor: `models/Element.jsx`
+ * uses inclusive bounds (`x + width - 1`), which is right for character cells
+ * and wrong for millimetres. A 40mm-wide box at x=10 occupies [10, 50) and its
+ * right edge — what Phase 3's alignment guides align to — is 50, not 49.
+ */
+
+/**
+ * Exclusive bounds in mm: { left, top, right, bottom, centerX, middleY }.
+ *
+ * Lines carry only the dimension they run along (`width` for hline, `height`
+ * for vline); their other extent is `thickness`. Without that substitution an
+ * hline would have bottom === top, so it would be a zero-height box: invisible
+ * to the rubber-band hit test and unalignable by Phase 3's guides.
+ */
+export function getA4Bounds(el) {
+ const isHLine = el.type === 'a4-hline';
+ const isVLine = el.type === 'a4-vline';
+ const thickness = el.thickness || 0;
+
+ const spanX = isVLine ? thickness : (el.width || 0);
+ const spanY = isHLine ? thickness : (el.height || 0);
+
+ const left = el.x;
+ const top = el.y;
+ const right = el.x + spanX;
+ const bottom = el.y + spanY;
+
+ return {
+ left,
+ top,
+ right,
+ bottom,
+ centerX: (left + right) / 2,
+ middleY: (top + bottom) / 2
+ };
+}
+
+/**
+ * Rubber-band hit test. `rect` is { minX, maxX, minY, maxY } in mm.
+ * Touching edges count as intersecting, matching the exclusive convention.
+ */
+export function a4IntersectsRect(el, rect) {
+ const b = getA4Bounds(el);
+ return !(
+ b.right < rect.minX ||
+ b.left > rect.maxX ||
+ b.bottom < rect.minY ||
+ b.top > rect.maxY
+ );
+}
+
+/** Clamp an element's top-left so its box stays inside the page. */
+export function clampToPage(x, y, width, height, pageW, pageH) {
+ return {
+ x: Math.max(0, Math.min(pageW - width, x)),
+ y: Math.max(0, Math.min(pageH - height, y))
+ };
+}
diff --git a/frontend/src/components/ReportEditor/a4/guides.js b/frontend/src/components/ReportEditor/a4/guides.js
new file mode 100644
index 0000000..cd4e4fc
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/guides.js
@@ -0,0 +1,174 @@
+/**
+ * Smart alignment guides for the A4 designer — pure functions, no React, no DOM.
+ *
+ * The feature the customer actually asked for: "helper lines to show if a field
+ * matches other fields' positions". Two halves:
+ *
+ * buildGuideCandidates() — every position worth aligning to, built ONCE per
+ * drag and held in a ref by A4Canvas.
+ * resolveSnap() — per mousemove: the nudged x/y plus the lines to draw.
+ *
+ * Both work in millimetres, on the EXCLUSIVE bounds from geometry.js. A 40mm box
+ * at x=10 has its right edge at 50, so aligning "right edge to right edge" means
+ * comparing 50 to 50 — with inclusive bounds the two boxes would sit 0.1mm apart
+ * and the guide would lie.
+ */
+
+import { PX_PER_MM, getPageSize } from './units';
+import { getA4Bounds } from './geometry';
+
+/**
+ * Snap threshold in SCREEN pixels, deliberately not millimetres.
+ *
+ * resolveSnap divides this by (PX_PER_MM * zoom), so the magnet's grab distance
+ * is a constant number of pixels under the cursor at every zoom level — which is
+ * what a user means by "it feels the same". A fixed mm threshold would instead
+ * feel sticky when zoomed in (6mm of screen travel at 150%) and useless when
+ * zoomed out. At 100% this works out to ~1.6mm.
+ */
+export const SNAP_PX = 6;
+
+/** Threshold in mm for the current zoom. Exported so tests can assert the
+ * inverse-zoom relationship directly rather than inferring it from snapping. */
+export const snapToleranceMm = (zoom) => SNAP_PX / (PX_PER_MM * zoom);
+
+/**
+ * Every position worth aligning to, split by axis.
+ *
+ * `V` holds vertical lines (constant x), `H` horizontal lines (constant y) —
+ * named for the line drawn, not the coordinate compared, because that is what
+ * the overlay renders.
+ *
+ * Bands are skipped as sources: a band always spans the full content width, so
+ * its left/right edges ARE the page margins, which are already emitted below.
+ * Emitting them again would produce duplicate candidates at identical positions
+ * and a doubled-up guide line. Band CHILDREN still generate candidates normally
+ * — they are ordinary elements that happen to live inside a band.
+ *
+ * @param {Array} elements all elements in the report
+ * @param {Array} excludeIds ids being dragged (they cannot align to themselves)
+ * @param {Object} pageSetup for page size and margins
+ * @returns {{V: Array, H: Array}} candidates as { pos, kind }
+ */
+export function buildGuideCandidates(elements, excludeIds, pageSetup) {
+ const excluded = new Set(excludeIds || []);
+ const V = [];
+ const H = [];
+
+ (elements || []).forEach(el => {
+ if (excluded.has(el.id)) return;
+ if (el.type === 'a4-band') return;
+
+ const b = getA4Bounds(el);
+ V.push(
+ { pos: b.left, kind: 'element' },
+ { pos: b.centerX, kind: 'element-center' },
+ { pos: b.right, kind: 'element' }
+ );
+ H.push(
+ { pos: b.top, kind: 'element' },
+ { pos: b.middleY, kind: 'element-center' },
+ { pos: b.bottom, kind: 'element' }
+ );
+ });
+
+ const size = getPageSize(pageSetup);
+ const m = (pageSetup && pageSetup.margins) || {};
+
+ V.push(
+ { pos: 0, kind: 'page' },
+ { pos: size.w / 2, kind: 'page-center' },
+ { pos: size.w, kind: 'page' },
+ { pos: m.left || 0, kind: 'margin' },
+ { pos: size.w - (m.right || 0), kind: 'margin' }
+ );
+ H.push(
+ { pos: 0, kind: 'page' },
+ { pos: size.h / 2, kind: 'page-center' },
+ { pos: size.h, kind: 'page' },
+ { pos: m.top || 0, kind: 'margin' },
+ { pos: size.h - (m.bottom || 0), kind: 'margin' }
+ );
+
+ return { V, H };
+}
+
+/**
+ * Best snap on one axis.
+ *
+ * The dragged rect offers three of its own edges (start / centre / end); every
+ * one is compared against every candidate and the single smallest gap wins. The
+ * correction returned is applied to the rect's ORIGIN, so it is the same value
+ * whichever of the three edges matched — `candidate.pos - myEdgePos` shifts the
+ * whole box by exactly the amount that puts that edge on the line.
+ *
+ * Ties keep the first match found, which puts element candidates ahead of page
+ * ones (they are pushed first) — aligning to a neighbour is the more intentional
+ * act when both are available at the same distance.
+ */
+function bestOnAxis(start, end, candidates, tolMm) {
+ const center = (start + end) / 2;
+ const myEdges = [
+ { pos: start, edge: 'start' },
+ { pos: center, edge: 'center' },
+ { pos: end, edge: 'end' }
+ ];
+
+ let best = null;
+
+ for (const c of candidates) {
+ for (const mine of myEdges) {
+ const dist = Math.abs(mine.pos - c.pos);
+ if (dist > tolMm) continue;
+ if (best === null || dist < best.dist) {
+ best = { dist, delta: c.pos - mine.pos, pos: c.pos, kind: c.kind };
+ }
+ }
+ }
+
+ return best;
+}
+
+/**
+ * Resolve a dragged rect against the candidate set.
+ *
+ * @param {Object} draftRect { x, y, width, height } in mm — the unsnapped
+ * position the drag would otherwise commit
+ * @param {Object} candidates from buildGuideCandidates
+ * @param {number} zoom current canvas zoom; converts SNAP_PX to mm
+ * @returns {{x, y, guides}} snapped origin and the lines to draw. `guides` is
+ * empty when nothing was within reach, so the caller
+ * can clear the overlay without a separate check.
+ */
+export function resolveSnap(draftRect, candidates, zoom) {
+ const tolMm = snapToleranceMm(zoom);
+ const { x, y, width = 0, height = 0 } = draftRect;
+
+ const v = bestOnAxis(x, x + width, candidates.V, tolMm);
+ const h = bestOnAxis(y, y + height, candidates.H, tolMm);
+
+ const guides = [];
+ if (v) guides.push({ axis: 'v', pos: v.pos, kind: v.kind });
+ if (h) guides.push({ axis: 'h', pos: h.pos, kind: h.kind });
+
+ return {
+ x: v ? x + v.delta : x,
+ y: h ? y + h.delta : y,
+ guides
+ };
+}
+
+/**
+ * Union of several elements' exclusive bounds, as a { x, y, width, height } rect.
+ *
+ * Multi-select drags snap this single rect and apply the resulting delta to
+ * every member. Snapping each element independently would let them find
+ * different candidates and tear the group apart.
+ */
+export function unionRect(boxes) {
+ const left = Math.min(...boxes.map(b => b.x));
+ const top = Math.min(...boxes.map(b => b.y));
+ const right = Math.max(...boxes.map(b => b.x + (b.width || 0)));
+ const bottom = Math.max(...boxes.map(b => b.y + (b.height || 0)));
+ return { x: left, y: top, width: right - left, height: bottom - top };
+}
diff --git a/frontend/src/components/ReportEditor/a4/layout.js b/frontend/src/components/ReportEditor/a4/layout.js
new file mode 100644
index 0000000..7e4bfa5
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/layout.js
@@ -0,0 +1,316 @@
+/**
+ * The A4 layout engine.
+ *
+ * A PURE function: no React, no DOM, no context. That is load-bearing, not
+ * stylistic — `printReport.js` calls it from outside any React tree, and a
+ * future Puppeteer service would call it server-side. Keep it that way.
+ *
+ * It is the single source of positioning: both the printed page and the
+ * on-screen preview consume its output, which is what makes "preview page
+ * count equals printed page count" a real guarantee rather than a coincidence.
+ *
+ * PHASE 6 SCOPE — bands, per-row data scoping and multi-page pagination.
+ */
+
+import { formatValue } from './formatValue';
+import { resolveDBTextValue } from '../utils/dataResolver';
+import { evaluateExpression } from '../utils/expressionEvaluator';
+import { calculateBandInstances } from './a4BandRenderer';
+import { getPageSize } from './units';
+
+/**
+ * Turn an expression's `[objectKey.fieldPath]` reference into a value.
+ *
+ * Ported from `ExpressionField.jsx:79-88`. The split is on the FIRST dot, so
+ * `[vehicle.driver.name]` resolves objectKey='vehicle', fieldPath='driver.name'.
+ *
+ * `scope` is the row-aware resolver below, so the same expression syntax means
+ * "the band's current row" inside a band and "the report data" outside one.
+ */
+export function makeFieldResolver(data, rowScope = null) {
+ return (ref) => {
+ const inner = ref.slice(1, ref.length - 1);
+ const dotIndex = inner.indexOf('.');
+ if (dotIndex === -1) {
+ return resolveInScope(data, rowScope, '', inner);
+ }
+ return resolveInScope(data, rowScope, inner.slice(0, dotIndex), inner.slice(dotIndex + 1));
+ };
+}
+
+/**
+ * Field resolution with an optional band row in scope.
+ *
+ * The matrix editor gets the current row from `BandContext` via React. Here it
+ * is a plain argument — simpler, and the only way layout() can stay pure.
+ * `resolveDBTextValue` already supports a row scope through its
+ * (parentBandId, bandContext) parameters, so the row is handed to it in that
+ * shape rather than duplicating its resolution rules.
+ *
+ * Precedence: the band row wins. A binding that the row does not satisfy falls
+ * back to the top-level report data, so a detail row can still print e.g.
+ * `[vehicle.number]` from the surrounding document.
+ */
+function resolveInScope(data, rowScope, objectKey, fieldPath) {
+ if (rowScope) {
+ const fromRow = resolveDBTextValue(
+ data, objectKey, fieldPath, ROW_SCOPE_ID, { currentBandData: { [ROW_SCOPE_ID]: rowScope } }
+ );
+ if (fromRow !== '') return fromRow;
+ }
+ return resolveDBTextValue(data, objectKey, fieldPath);
+}
+
+/** Internal key for the synthetic single-entry band context above. */
+const ROW_SCOPE_ID = '__a4_row__';
+
+/**
+ * Text for one element. Data binding is resolved HERE, not in the renderer, so
+ * PrintDocument receives finished strings and stays dumb — and so a future
+ * server-side Puppeteer run needs no browser data layer at all.
+ *
+ * The geometry-only types (rect, hline, vline) deliberately fall through to '';
+ * they are positioned like everything else but carry no text.
+ */
+function resolveText(el, data, rowScope = null) {
+ switch (el.type) {
+ case 'a4-text':
+ return el.content ?? '';
+
+ case 'a4-dbtext': {
+ const raw = resolveInScope(data, rowScope, el.objectKey, el.fieldPath);
+ return formatValue(raw, el.dataType || 'general');
+ }
+
+ case 'a4-expr': {
+ const raw = evaluateExpression(el.expression, makeFieldResolver(data, rowScope));
+ return formatValue(raw, el.dataType || 'general');
+ }
+
+ default:
+ // Geometry-only types, and unknown types: an unknown type renders empty
+ // rather than throwing, so a half-migrated report still prints the
+ // elements it can.
+ return '';
+ }
+}
+
+/**
+ * Split laid-out band instances across pages.
+ *
+ * The model is the standard banded report (FastReport/Crystal): EVERYTHING
+ * keeps the position it was drawn at, and the only thing that moves anything is
+ * a band GROWING as it repeats per data row. Growth pushes whatever sits below
+ * it — later bands and static elements alike — down by the same delta, so the
+ * gaps the user drew survive.
+ *
+ * - Instances are ATOMIC. A band that does not fit starts a new page rather
+ * than being sliced, because half a table row on each side of a page break is
+ * never what anyone wants on a weighing ticket.
+ * - `header` is a COLUMN header, not a page header. It prints where it was
+ * drawn, and repeats at the top of a CONTINUATION page — a page reached
+ * because the flow overran, so the rows it captions carry on beneath it.
+ * - `footer` bands sit at `contentBottom - height` on every page.
+ * - `summary` emits once, after all other instances.
+ *
+ * @param {Array} instances from calculateBandInstances, in flow order
+ * @param {number} contentTop mm
+ * @param {number} contentBottom mm
+ * @param {Function} emit (pageIndex, instance, y) => void
+ * @returns {number} the number of pages used (>= 1)
+ */
+export function paginate(instances, contentTop, contentBottom, emit) {
+ const headers = instances.filter(i => i.bandType === 'header');
+ const footers = instances.filter(i => i.bandType === 'footer');
+ const summaries = instances.filter(i => i.bandType === 'summary');
+ // Everything that streams down the page, headers included: a header prints in
+ // its designed place first, and only REPEATS on a continuation page.
+ const flow = instances.filter(
+ i => i.bandType !== 'footer' && i.bandType !== 'summary'
+ );
+
+ const headerHeight = headers.reduce((sum, h) => sum + h.height, 0);
+ const footerHeight = footers.reduce((sum, f) => sum + f.height, 0);
+
+ let pageIndex = 0;
+ // Tracks the running bottom of placed content. It starts at contentTop so the
+ // first instance can sit at its design Y, however far down that is.
+ let cursorY = contentTop;
+
+ const flowBottom = () => contentBottom - footerHeight;
+
+ /** Repeat the column headers at the top of a continuation page. */
+ const startContinuationPage = (index) => {
+ let y = contentTop;
+ headers.forEach(h => {
+ emit(index, h, y);
+ y += h.height;
+ });
+ return y;
+ };
+
+ const place = (instance) => {
+ // Design position, unless growth above has already pushed past it. max()
+ // is the whole model in one line: nothing moves up, and anything below a
+ // grown band is displaced by exactly that growth.
+ const designY = typeof instance.y === 'number' ? instance.y : cursorY;
+ let y = Math.max(designY, cursorY);
+
+ // A band taller than the usable area can never fit anywhere; place it and
+ // let it clip rather than paging forever looking for room.
+ const usable = contentBottom - contentTop - headerHeight - footerHeight;
+ const fitsAnywhere = instance.height <= usable;
+
+ if (y + instance.height > flowBottom() && fitsAnywhere) {
+ pageIndex += 1;
+ y = startContinuationPage(pageIndex);
+ }
+
+ emit(pageIndex, instance, y);
+ cursorY = y + instance.height;
+ };
+
+ flow.forEach(place);
+ summaries.forEach(place);
+
+ const pageCount = pageIndex + 1;
+
+ // Footers last: positioned from the BOTTOM, so they need the final page
+ // count, and they repeat on every page.
+ for (let p = 0; p < pageCount; p += 1) {
+ let y = contentBottom - footerHeight;
+ footers.forEach(f => {
+ emit(p, f, y);
+ y += f.height;
+ });
+ }
+
+ return pageCount;
+}
+
+/**
+ * @param {Array} elements report elements, positions in mm floats
+ * @param {Object} data resolved report data
+ * @param {Object} pageSetup { page, orientation, margins, defaultFont, defaultFontSize }
+ * @returns {{ pages: Array
> }}
+ */
+export function layout(elements, data, pageSetup) {
+ const list = Array.isArray(elements) ? elements : [];
+ const bands = list.filter(el => el && el.type === 'a4-band');
+
+ const pageSize = getPageSize(pageSetup);
+ const margins = pageSetup?.margins || {};
+ const contentTop = margins.top || 0;
+ const contentBottom = pageSize.h - (margins.bottom || 0);
+
+ // Ids of every element owned by a band — they are positioned by their band's
+ // instance, never at their own top-level y.
+ const childIds = new Set();
+ bands.forEach(b => (b.children || []).forEach(id => childIds.add(id)));
+
+ const statics = list.filter(
+ el => el && el.type !== 'a4-band' && !childIds.has(el.id)
+ );
+
+ const pages = [[]];
+ const ensurePage = (index) => {
+ while (pages.length <= index) pages.push([]);
+ return pages[index];
+ };
+
+ // How far the flow was displaced past a given design Y, and onto which page.
+ // Built as bands are placed, then applied to the statics below them: a static
+ // drawn under a band keeps its drawn gap when that band grows with its rows.
+ // Each entry is { designEnd, actualEnd, pageIndex } for one placed instance.
+ const displacements = [];
+
+ if (bands.length > 0) {
+ const instances = calculateBandInstances(bands, data, list);
+
+ paginate(instances, contentTop, contentBottom, (pageIndex, instance, y) => {
+ const target = ensurePage(pageIndex);
+ // Keyed on the band's DESIGN bottom (originalBandY + height), not on the
+ // instance's own y: calculateBandInstances has already stacked the repeats,
+ // so instance.y is where row N landed, while every row of one band shares
+ // a single design position. A static is drawn below the band as drawn.
+ if (typeof instance.originalBandY === 'number') {
+ displacements.push({
+ designEnd: instance.originalBandY + instance.height,
+ actualEnd: y + instance.height,
+ pageIndex
+ });
+ }
+ // A child's stored x and y are BOTH relative to its band — the canvas
+ // renders children inside the band div, so CSS adds the band's origin.
+ // layout() has to add it explicitly or a child prints measured from the
+ // paper edge instead of from the band's left margin.
+ const bandX = instance.bandX || 0;
+
+ instance.childElements.forEach(child => {
+ target.push({
+ // A child is emitted once per instance, so its key must be unique per
+ // instance — PrintDocument keys on el.id, and React would otherwise
+ // see duplicate keys across 60 detail rows.
+ el: child,
+ x: bandX + child.x,
+ // The child's stored y is RELATIVE to its band, exactly as the matrix
+ // editor stores it.
+ y: y + child.y,
+ text: resolveText(child, data, instance.data),
+ instanceKey: `${instance.bandId}-${instance.instanceIndex}-${pageIndex}`
+ });
+ });
+ });
+ }
+
+ /**
+ * Place a static, pushed down by any band growth above it.
+ *
+ * The governing band is the LAST one whose designed bottom is at or above the
+ * static: that is the thing it was drawn beneath. The static then keeps the
+ * exact gap it was drawn with, measured from that band's ACTUAL bottom.
+ *
+ * A static above every band has no governing band and so does not move —
+ * a title or letterhead stays where it was put.
+ */
+ const placeStatic = (el) => {
+ let governing = null;
+ for (const d of displacements) {
+ if (d.designEnd > el.y) continue;
+ // Deepest design position wins; on a TIE take the one that ended up
+ // furthest down. Every instance of one repeating band shares a designEnd,
+ // so a tie is the normal case — keeping the first would measure the gap
+ // from row 1 and let the static sit on top of rows 2..n.
+ if (!governing ||
+ d.designEnd > governing.designEnd ||
+ (d.designEnd === governing.designEnd && d.actualEnd > governing.actualEnd)) {
+ governing = d;
+ }
+ }
+
+ if (!governing) {
+ pages[0].push({ el, x: el.x, y: el.y, text: resolveText(el, data) });
+ return;
+ }
+
+ const gap = el.y - governing.designEnd;
+ let y = governing.actualEnd + gap;
+ let pageIndex = governing.pageIndex;
+
+ // Pushed past the bottom margin: carry on at the top of the next page
+ // rather than vanishing off the sheet.
+ if (y + (el.height || 0) > contentBottom) {
+ pageIndex += 1;
+ y = contentTop;
+ }
+
+ ensurePage(pageIndex).push({ el, x: el.x, y, text: resolveText(el, data) });
+ };
+
+ statics.forEach(placeStatic);
+
+ return { pages };
+}
+
+export { formatValue };
+export default layout;
diff --git a/frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx b/frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx
new file mode 100644
index 0000000..613a6dc
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { getFontStack } from '../fonts';
+import './print.css';
+
+/**
+ * Renders layout() output as absolutely-positioned millimetre HTML.
+ *
+ * DELIBERATELY DUMB. All positioning and all data resolution happened in
+ * layout(); this maps finished {el, x, y, text} records onto CSS mm and does
+ * nothing else — no hooks, no context, no state, no CSS-in-JS runtime. That is
+ * what lets a future Puppeteer service consume this exact markup by POSTing the
+ * serialised HTML, instead of needing a redesign (see the plan's §6 "socket").
+ *
+ * Font size is emitted in CSS `pt` with no conversion at all: the element
+ * stores pt, CSS has real pt units, and the print path is physical. The screen
+ * path in A4TextField.jsx is the one that has to convert (pt -> px * zoom).
+ *
+ * The same FONT_STACKS strings are used on screen and here, so WYSIWYG holds by
+ * construction — one font stack, one rendering engine.
+ */
+/**
+ * Inline style for one placed element.
+ *
+ * Every branch emits TRUE millimetres. The screen renderers floor hairlines at
+ * 1px so they stay visible when zoomed out; nothing of the sort happens here,
+ * because the printed thickness is the thickness the user measures.
+ */
+export function styleFor(el, x, y) {
+ const base = { left: `${x}mm`, top: `${y}mm` };
+
+ switch (el.type) {
+ case 'a4-rect':
+ return {
+ ...base,
+ width: `${el.width}mm`,
+ height: `${el.height}mm`,
+ // box-sizing: border-box (print.css) keeps the border INSIDE the
+ // declared mm, so the rect's outer footprint is what was designed.
+ border: el.borderWidth > 0
+ ? `${el.borderWidth}mm solid ${el.borderColor || '#000000'}`
+ : 'none',
+ background: el.fillColor || 'transparent'
+ };
+
+ case 'a4-hline':
+ return {
+ ...base,
+ width: `${el.width}mm`,
+ height: `${el.thickness}mm`,
+ background: el.color || '#000000'
+ };
+
+ case 'a4-vline':
+ return {
+ ...base,
+ width: `${el.thickness}mm`,
+ height: `${el.height}mm`,
+ background: el.color || '#000000'
+ };
+
+ default:
+ // Text types: a4-text, a4-dbtext, a4-expr. They differ only in where
+ // their string came from, which layout() already settled.
+ return {
+ ...base,
+ width: `${el.width}mm`,
+ height: `${el.height}mm`,
+ fontFamily: getFontStack(el.fontFamily),
+ fontSize: `${el.fontSize}pt`,
+ fontWeight: el.bold ? 700 : 400,
+ fontStyle: el.italic ? 'italic' : 'normal',
+ textAlign: el.align || 'left',
+ color: el.color || '#000000'
+ };
+ }
+}
+
+function PrintDocument({ pages, pageSetup }) {
+ const isLandscape = pageSetup?.orientation === 'landscape';
+ const pageClass = `a4-print-page${isLandscape ? ' a4-print-page--landscape' : ''}`;
+
+ return (
+
+ {(pages || []).map((els, i) => (
+
+ {els.map(({ el, x, y, text, instanceKey }) => (
+
+ {text}
+
+ ))}
+
+ ))}
+
+ );
+}
+
+export default PrintDocument;
diff --git a/frontend/src/components/ReportEditor/a4/print/print.css b/frontend/src/components/ReportEditor/a4/print/print.css
new file mode 100644
index 0000000..9ff6356
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/print/print.css
@@ -0,0 +1,103 @@
+/**
+ * Print styles for the A4 designer.
+ *
+ * Millimetre accuracy comes from CSS physical units — Chrome maps `mm` to real
+ * millimetres at 100% scale. This is the whole reason there is no PDF library:
+ * the browser prints with the fonts installed on the station, and "Save as PDF"
+ * is the operator choosing that destination in the same dialog.
+ *
+ * TWO NAMING NOTES, both deliberate:
+ *
+ * 1. The page class is `.a4-print-page`, NOT `.a4-page`. A4Canvas.css already
+ * styles `.a4-page` for the on-screen editor canvas (background, shadow),
+ * and both stylesheets are loaded while the editor is open — sharing the
+ * name would make them fight.
+ *
+ * 2. `.print-root` must sit on the element that is a DIRECT CHILD OF ,
+ * because the hiding rule below is `body > *:not(.print-root)`. printReport.js
+ * therefore puts the class on the container it appends to document.body, and
+ * PrintDocument repeats it on its own outer div for the standalone case.
+ * Get this wrong and you print either a blank page or the entire app.
+ */
+
+@page {
+ size: A4 portrait;
+ margin: 0;
+}
+
+/* Landscape: `@page { size }` cannot be set from an inline style or a class, so
+ printReport.js injects a one-line `@page { size: A4 landscape; margin: 0 }`
+ stylesheet that overrides the rule above. The page BOX still needs swapped
+ dimensions, which is what .a4-print-page--landscape below does. */
+
+.a4-print-page {
+ width: 210mm;
+ height: 297mm;
+ position: relative;
+ overflow: hidden;
+ background: #fff;
+ page-break-after: always;
+ break-after: page;
+}
+
+.a4-print-page:last-child {
+ page-break-after: auto;
+ break-after: auto;
+}
+
+.a4-print-page--landscape {
+ width: 297mm;
+ height: 210mm;
+}
+
+.a4-print-el {
+ position: absolute;
+ /* index.css sets `* { box-sizing: border-box }` globally; with no padding or
+ border on these boxes that is a no-op, but state it so a future border
+ (Phase 5's a4-rect) does not silently change the printed width. */
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ /* Print exactly the designed colours; without this Chrome may drop
+ backgrounds and lighten text in some print paths. */
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+}
+
+/* Geometry-only types carry no text, so they must not inherit a line-height or
+ font metric that could add a fraction of a millimetre to the painted box.
+ The height of an hline IS its measured thickness. */
+.a4-print-el--a4-rect,
+.a4-print-el--a4-hline,
+.a4-print-el--a4-vline {
+ font-size: 0;
+ line-height: 0;
+}
+
+/* NOTE: there is deliberately no `.a4-calibration-mark` rule here any more.
+ The calibration marks are now REAL a4-hline / a4-vline elements that paint
+ their own background from `color`, so a class-level background would only
+ be able to contradict the element's own colour. print-color-adjust above is
+ what keeps them solid black on paper. */
+
+/* Hidden on screen — the print root is mounted into the live DOM, so it must
+ not appear over the editor. */
+.print-root {
+ display: none;
+}
+
+@media print {
+ body > *:not(.print-root) {
+ display: none !important;
+ }
+
+ .print-root {
+ display: block !important;
+ }
+
+ body {
+ margin: 0 !important;
+ padding: 0 !important;
+ background: #fff !important;
+ }
+}
diff --git a/frontend/src/components/ReportEditor/a4/print/printReport.jsx b/frontend/src/components/ReportEditor/a4/print/printReport.jsx
new file mode 100644
index 0000000..517f515
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/print/printReport.jsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { flushSync } from 'react-dom';
+import PrintDocument from './PrintDocument';
+import { layout } from '../layout';
+
+/**
+ * Non-React entry point for printing an A4 report.
+ *
+ * Callable from anywhere — Main.jsx will call it in Phase 7 with no editor
+ * mounted. That works only because layout() is pure, so nothing here needs a
+ * React context, a provider, or the component tree.
+ *
+ * "Save as PDF" is THE SAME CALL: the operator picks that destination in
+ * Chrome's own print dialog. There is no second code path and no PDF library —
+ * which is what lets the output use the fonts installed on the station.
+ *
+ * MOUNTING: the container is appended directly to and carries the
+ * `print-root` class ITSELF. print.css hides everything else with
+ * `body > *:not(.print-root)`, which only matches direct children of body — so
+ * the class has to be on the appended container, not merely on PrintDocument's
+ * inner div. The app's own root () is a sibling and gets hidden.
+ */
+
+/** Injected only for landscape: `@page { size }` cannot come from a class. */
+const LANDSCAPE_STYLE_ID = 'a4-print-landscape-page-rule';
+
+function applyLandscapePageRule() {
+ if (document.getElementById(LANDSCAPE_STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = LANDSCAPE_STYLE_ID;
+ style.textContent = '@page { size: A4 landscape; margin: 0; }';
+ document.head.appendChild(style);
+}
+
+function removeLandscapePageRule() {
+ document.getElementById(LANDSCAPE_STYLE_ID)?.remove();
+}
+
+/**
+ * @param {Object} report { elements, pageSetup | page_setup }
+ * @param {Object} data resolved report data (used from Phase 5 onward)
+ * @returns {Promise
} resolves once the print container has been removed
+ */
+export async function printA4Report(report, data) {
+ // Accept both the editor's in-memory shape (pageSetup) and the API shape
+ // (page_setup), so the Print button works on an unsaved report and Main.jsx
+ // can pass a freshly fetched row unchanged.
+ const pageSetup = report?.pageSetup || report?.page_setup || {};
+ const { pages } = layout(report?.elements, data, pageSetup);
+
+ const container = document.createElement('div');
+ container.className = 'print-root';
+ document.body.appendChild(container);
+
+ if (pageSetup.orientation === 'landscape') applyLandscapePageRule();
+
+ const root = createRoot(container);
+
+ // window.print() reads the DOM synchronously, but React 18 commits its render
+ // asynchronously — so without flushSync the dialog can open on an EMPTY
+ // container and print a blank page. flushSync forces the commit to finish
+ // before print() is called.
+ //
+ // Deliberately not requestAnimationFrame: rAF does not fire in a background
+ // or hidden tab, which would leave a print click hanging forever instead of
+ // printing. flushSync has no such dependency on the frame loop.
+ flushSync(() => {
+ root.render( );
+ });
+
+ return new Promise(resolve => {
+ let done = false;
+ // Declared before cleanup() so that a synchronous afterprint — Chrome fires
+ // it from inside window.print() — does not hit the temporal dead zone.
+ let fallbackTimer = null;
+
+ const cleanup = () => {
+ if (done) return;
+ done = true;
+ window.removeEventListener('afterprint', cleanup);
+ clearTimeout(fallbackTimer);
+ removeLandscapePageRule();
+ // Unmount asynchronously: React forbids unmounting a root from inside a
+ // lifecycle/event it is already rendering within.
+ setTimeout(() => {
+ try {
+ root.unmount();
+ } catch (err) {
+ // The container may already have been detached (a host page tearing
+ // down, a test harness cleaning up). Removing it is what actually
+ // matters, so never let an unmount error skip that.
+ console.error('Print teardown failed to unmount cleanly:', err);
+ }
+ container.remove();
+ resolve();
+ }, 0);
+ };
+
+ window.addEventListener('afterprint', cleanup);
+
+ // Not every browser fires afterprint (and some never fire it if the dialog
+ // is dismissed). Without this fallback, containers accumulate in the DOM on
+ // every print — one full report's markup each time.
+ fallbackTimer = setTimeout(cleanup, 60000);
+
+ try {
+ window.print();
+ } catch (err) {
+ // A blocked or unavailable print dialog must still tear the container
+ // down, or the editor is left with hidden markup stuck in .
+ console.error('Print failed:', err);
+ cleanup();
+ }
+ });
+}
+
+export default printA4Report;
diff --git a/frontend/src/components/ReportEditor/a4/sampleData.js b/frontend/src/components/ReportEditor/a4/sampleData.js
new file mode 100644
index 0000000..195c189
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/sampleData.js
@@ -0,0 +1,68 @@
+/**
+ * Sample vehicle data for designing bindings when no real vehicle was passed.
+ *
+ * DUPLICATED, NOT EXTRACTED. The plan (§3) preferred moving this out of
+ * `ReportEditor.jsx:71-116` into a shared `utils/sampleData.js` so the two
+ * editors could not drift. `ReportEditor.jsx` is on the protected list for this
+ * phase, and extracting means editing it, so the plan's own fallback applies:
+ * duplicate instead. The matrix copy remains the original; if that file is ever
+ * unfrozen, delete this and import the shared one.
+ *
+ * Kept byte-for-byte equivalent to the matrix version — do not "improve" it, or
+ * a report designed against A4 sample data will bind differently from the same
+ * report designed in the matrix editor.
+ */
+
+/**
+ * Build a report-data object from nomenclature definitions/entries.
+ *
+ * @param {Object} definitions code → nomenclature definition (from NomenclatureDataContext)
+ * @param {Object} entries code → array of entries for lookup nomenclatures
+ * @returns {Object} `{ vehicle: {...} }`, the shape DataContext expects
+ */
+export function buildSampleVehicleData(definitions, entries) {
+ const extra = {};
+ for (const def of Object.values(definitions || {})) {
+ if (def.kind === 'lookup') {
+ const activeEntries = ((entries || {})[def.code] || []).filter(e => e.is_active);
+ const firstEntry = activeEntries[0];
+ if (firstEntry?.data && Object.keys(firstEntry.data).length > 0) {
+ extra[def.name] = { ...firstEntry.data };
+ } else if (def.fields?.length > 0) {
+ const sample = {};
+ for (const field of def.fields) {
+ if (field.field_type === 'number') sample[field.key] = 0;
+ else if (field.field_type === 'bool') sample[field.key] = false;
+ else sample[field.key] = 'Sample';
+ }
+ extra[def.name] = sample;
+ } else {
+ extra[def.name] = {};
+ }
+ } else {
+ extra[def.name] = 'Sample Text';
+ }
+ }
+
+ return {
+ vehicle: {
+ vehicle_number: 'АА1234ВВ',
+ trailer1_number: 'ПВ5678ВВ',
+ trailer2_number: null,
+ driver_pid: '123456789',
+ tare: 8500,
+ tare_date: '2026-02-22T08:00:00Z',
+ tare_user_name: 'operator',
+ gross: 28500,
+ gross_date: '2026-02-22T10:30:00Z',
+ gross_user_name: 'operator',
+ net: 20000,
+ net_date: '2026-02-22T10:30:00Z',
+ net_user_name: 'operator',
+ doc_number: '2026-001',
+ extra
+ }
+ };
+}
+
+export default buildSampleVehicleData;
diff --git a/frontend/src/components/ReportEditor/a4/units.js b/frontend/src/components/ReportEditor/a4/units.js
new file mode 100644
index 0000000..5dea948
--- /dev/null
+++ b/frontend/src/components/ReportEditor/a4/units.js
@@ -0,0 +1,34 @@
+/**
+ * Unit conversions for the A4 designer.
+ *
+ * Storage is ALWAYS millimetre floats — never pixels, never zoom-affected values.
+ * Pixels exist only at render time; `zoom` never reaches the stored report JSON.
+ *
+ * Font size is stored in pt (what users expect). On screen it renders as
+ * pt * (96/72) * zoom px; on the print path CSS `pt` is used directly.
+ */
+
+export const PX_PER_MM = 96 / 25.4; // 3.7795 — CSS px per mm at 100% zoom
+export const PX_PER_PT = 96 / 72; // 1.3333 — CSS px per pt at 100% zoom
+
+export const mmToPx = (mm, zoom = 1) => mm * PX_PER_MM * zoom;
+export const pxToMm = (px, zoom = 1) => px / (PX_PER_MM * zoom);
+
+/** Screen pixel size for a pt font at the current zoom. */
+export const ptToPx = (pt, zoom = 1) => pt * PX_PER_PT * zoom;
+
+/** 0.1 mm storage precision — keeps 12.700000000000001 out of the JSON. */
+export const roundMm = (mm) => Math.round(mm * 10) / 10;
+
+export const PAGE = {
+ A4: {
+ portrait: { w: 210, h: 297 },
+ landscape: { w: 297, h: 210 }
+ }
+};
+
+/** Page dimensions in mm for a page_setup object. */
+export function getPageSize(pageSetup) {
+ const page = PAGE[pageSetup?.page] || PAGE.A4;
+ return page[pageSetup?.orientation === 'landscape' ? 'landscape' : 'portrait'];
+}
diff --git a/frontend/src/components/ReportEditor/hooks/useElementDrag.jsx b/frontend/src/components/ReportEditor/hooks/useElementDrag.jsx
index cc93634..a157780 100644
--- a/frontend/src/components/ReportEditor/hooks/useElementDrag.jsx
+++ b/frontend/src/components/ReportEditor/hooks/useElementDrag.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
/**
* Custom hook for handling element dragging with grid snapping
@@ -13,15 +13,30 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
const [isDragging, setIsDragging] = useState(false);
const [dragData, setDragData] = useState(null);
+ // onDrag and dragData are read through refs so the effect below can depend on
+ // `isDragging` ALONE. Depending on them directly re-ran the effect on every
+ // render during a drag, and each re-run removed the mouseup listener before
+ // adding a new one. A mouseup landing in that gap was lost and the element
+ // kept following the mouse until the next click. Snapping made it routine:
+ // a held guide line stops x/y changing, so the only state still changing is
+ // activeGuides, and that render lands between the mousemove and the mouseup.
+ const onDragRef = useRef(onDrag);
+ onDragRef.current = onDrag;
+ const dragDataRef = useRef(dragData);
+ dragDataRef.current = dragData;
+
const handleMouseDown = (e, elementId) => {
// Only handle dragging in select mode
if (toolMode !== 'select') return;
e.stopPropagation();
- setIsDragging(true);
const data = onDragStart(elementId, e.clientX, e.clientY);
- setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
+ // Seed the ref too: the listeners below subscribe on this same render pass,
+ // so a mousemove can arrive before the state write has been applied.
+ dragDataRef.current = { ...data, startMouseX: e.clientX, startMouseY: e.clientY };
+ setIsDragging(true);
+ setDragData(dragDataRef.current);
};
useEffect(() => {
@@ -31,16 +46,18 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
document.body.classList.add('dragging-active');
const handleMouseMove = (e) => {
- if (dragData) {
- const deltaX = e.clientX - dragData.startMouseX;
- const deltaY = e.clientY - dragData.startMouseY;
- onDrag(dragData, deltaX, deltaY);
+ const data = dragDataRef.current;
+ if (data) {
+ const deltaX = e.clientX - data.startMouseX;
+ const deltaY = e.clientY - data.startMouseY;
+ onDragRef.current(data, deltaX, deltaY);
}
};
const handleMouseUp = () => {
setIsDragging(false);
setDragData(null);
+ dragDataRef.current = null;
document.body.classList.remove('dragging-active');
};
@@ -52,7 +69,7 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
document.removeEventListener('mouseup', handleMouseUp);
document.body.classList.remove('dragging-active');
};
- }, [isDragging, dragData, onDrag]);
+ }, [isDragging]);
return {
isDragging,
diff --git a/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx b/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx
index b1a6875..8e8f47b 100644
--- a/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx
+++ b/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx
@@ -7,7 +7,7 @@ import { parseReading, formatHex } from '../../services/scaleProtocols';
import api from '../../services/api';
import './ScaleMonitor.css';
-const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
+const SERIAL_URL = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
const EMPTY_BUILDER = {
name: '',
diff --git a/frontend/src/contexts/LicenseContext.jsx b/frontend/src/contexts/LicenseContext.jsx
index 5d5ea2a..835cba1 100644
--- a/frontend/src/contexts/LicenseContext.jsx
+++ b/frontend/src/contexts/LicenseContext.jsx
@@ -3,7 +3,7 @@ import api from '../services/api';
const LicenseContext = createContext(null);
-const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
+const SERIAL_URL = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
const HEARTBEAT_MS = 30000; // server releases a slot after 120s, so 4 missed beats
export const useLicense = () => {
diff --git a/frontend/src/contexts/NomenclatureContext.jsx b/frontend/src/contexts/NomenclatureContext.jsx
index 2c58919..bd17c0a 100644
--- a/frontend/src/contexts/NomenclatureContext.jsx
+++ b/frontend/src/contexts/NomenclatureContext.jsx
@@ -1,6 +1,6 @@
import { createContext, useContext, useState, useEffect, useRef } from 'react';
-const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const NomenclatureContext = createContext();
diff --git a/frontend/src/hooks/useSerialData.js b/frontend/src/hooks/useSerialData.js
index 2b0003b..19c6646 100644
--- a/frontend/src/hooks/useSerialData.js
+++ b/frontend/src/hooks/useSerialData.js
@@ -6,7 +6,7 @@ function useSerialData() {
const [error, setError] = useState(null);
useEffect(() => {
- const serialUrl = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
+ const serialUrl = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
const eventSource = new EventSource(`${serialUrl}/events`);
eventSource.onopen = () => {
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index a47af7e..1c7b139 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -1,6 +1,6 @@
import axios from 'axios';
-const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_BASE_URL,
diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs
new file mode 100644
index 0000000..c939de5
--- /dev/null
+++ b/frontend/vite.config.mjs
@@ -0,0 +1,37 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ // CRA's Babel loader accepted JSX inside .js files. Production sources have
+ // been renamed to .jsx, but the a4 test files are still .js containing JSX,
+ // so the transform is widened to parse .js as JSX too. This works on the
+ // dev/test path; the bundled build path derives the language from the file
+ // extension, which is why the production file had to be renamed.
+ plugins: [react({ include: /\.(js|jsx)$/ })],
+ oxc: {
+ include: /\.(m?ts|[jt]sx|js)$/,
+ exclude: /node_modules/,
+ lang: 'jsx',
+ },
+ server: {
+ // Must stay 5172 — Django's CORS_ALLOWED_ORIGINS allowlists this origin.
+ port: 5172,
+ strictPort: true,
+ // Replicates CRA's "proxy": "http://localhost:8002". The app calls the API
+ // with absolute URLs (VITE_API_URL), so this only matters as a fallback
+ // for a relative /api request.
+ proxy: {
+ '/api': 'http://localhost:8002',
+ },
+ },
+ build: {
+ // Kept as CRA's 'build' rather than Vite's default 'dist' so the existing
+ // Dockerfile (COPY --from=builder /app/build) keeps working.
+ outDir: 'build',
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ include: ['src/**/*.test.{js,jsx}'],
+ },
+});