mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 08:18:22 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { Alert, Button, Code, List, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ImportResult } from '../config/html-import';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
result: ImportResult | null;
|
||||
/** Replaces the draft's blocks/background with the imported ones. */
|
||||
onApply: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of "Import to canvas" — how many blocks landed, and an itemized
|
||||
* list of what could not be placed and why.
|
||||
*
|
||||
* Shown before the import is applied (not after) so a design that resolves
|
||||
* to nothing (a flex/table-heavy hand-written template — see
|
||||
* `html-import.ts`'s own doc comment) is not silently swapped in as an empty
|
||||
* canvas; the author decides whether the partial result is still worth
|
||||
* having.
|
||||
*/
|
||||
export function ImportHtmlModal({ opened, onClose, result, onApply }: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (!result) return null;
|
||||
|
||||
const { placements, unmapped, backgroundUrl } = result;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t('designer.importTitle', 'Import to canvas')} size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color={placements.length > 0 ? 'teal' : 'yellow'}
|
||||
icon={placements.length > 0 ? <IconCircleCheck size={18} /> : <IconAlertTriangle size={18} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{placements.length > 0
|
||||
? t(
|
||||
'designer.importSummary',
|
||||
'{{count}} element(s) placed on the canvas{{bg}}.',
|
||||
{
|
||||
count: placements.length,
|
||||
bg: backgroundUrl ? t('designer.importAndBackground', ', plus the background image') : '',
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'designer.importNothingPlaced',
|
||||
'Nothing could be placed on the canvas. This design uses layout the canvas cannot represent — see below.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{unmapped.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('designer.importUnmappedTitle', '{{count}} element(s) need manual placement:', {
|
||||
count: unmapped.length,
|
||||
})}
|
||||
</Text>
|
||||
<List size="sm" spacing={4} icon={
|
||||
<ThemeIcon color="yellow" size={16} radius="xl" variant="light">
|
||||
<IconAlertTriangle size={10} />
|
||||
</ThemeIcon>
|
||||
}>
|
||||
{unmapped.map((w, i) => (
|
||||
<List.Item key={i}>
|
||||
<Code fz={11}>{w.element}</Code> — {w.reason}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'designer.importOverwriteWarning',
|
||||
'Applying replaces every block currently on the canvas for this version.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Button.Group>
|
||||
<Button variant="default" onClick={onClose} style={{ flex: 1 }}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={onApply} disabled={placements.length === 0 && !backgroundUrl} style={{ flex: 1 }}>
|
||||
{t('designer.importApply', 'Apply to canvas')}
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { Group, Paper, Switch, Text, TextInput, Textarea } from '@mantine/core';
|
||||
import { Button, Group, Paper, Switch, Text, TextInput, Textarea, Tooltip } from '@mantine/core';
|
||||
import { IconFileImport } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
@@ -12,6 +13,8 @@ interface Props {
|
||||
editorRef: RefObject<HTMLTextAreaElement | null>;
|
||||
disabled: boolean;
|
||||
isPublished: boolean;
|
||||
/** Parses `source` and opens the import report — undefined hides the button entirely. */
|
||||
onImportToCanvas?: () => void;
|
||||
}
|
||||
|
||||
/** Name, orientation and the Handlebars source for the selected version. */
|
||||
@@ -25,6 +28,7 @@ export function TemplateEditor({
|
||||
editorRef,
|
||||
disabled,
|
||||
isPublished,
|
||||
onImportToCanvas,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,6 +48,24 @@ export function TemplateEditor({
|
||||
onChange={(e) => onLandscapeChange(e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{onImportToCanvas && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
'designer.importHint',
|
||||
'Reads this HTML and places whatever it can as canvas blocks — a report lists what could not be placed.',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftSection={<IconFileImport size={15} />}
|
||||
disabled={disabled || !source.trim()}
|
||||
onClick={onImportToCanvas}
|
||||
>
|
||||
{t('designer.importToCanvas', 'Import to canvas')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isPublished && (
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { htmlToPlacements, parseInlineStyle, type ImportedElement } from './html-import';
|
||||
import { compileLayoutToHbs } from './layout-compiler';
|
||||
import type { TemplateFieldPlacement } from '@ema-platform/api';
|
||||
|
||||
/** Builds a fixture element without having to repeat every empty field. */
|
||||
function el(
|
||||
tag: string,
|
||||
opts: Partial<Pick<ImportedElement, 'style' | 'attrs' | 'text' | 'children'>> = {},
|
||||
): ImportedElement {
|
||||
return {
|
||||
tag,
|
||||
style: opts.style ?? {},
|
||||
attrs: opts.attrs ?? {},
|
||||
text: opts.text ?? '',
|
||||
children: opts.children ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** style="..." attribute value → the parsed-declaration object the walker expects. */
|
||||
function styled(css: string): Record<string, string> {
|
||||
return parseInlineStyle(css);
|
||||
}
|
||||
|
||||
describe('parseInlineStyle', () => {
|
||||
it('splits declarations and lowercases property names', () => {
|
||||
expect(parseInlineStyle('Position: absolute; Left:10%; width : 30% ')).toEqual({
|
||||
position: 'absolute',
|
||||
left: '10%',
|
||||
width: '30%',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed declarations and empty input', () => {
|
||||
expect(parseInlineStyle('not-a-declaration')).toEqual({});
|
||||
expect(parseInlineStyle(undefined)).toEqual({});
|
||||
expect(parseInlineStyle(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlacements — text blocks', () => {
|
||||
it('places an absolutely-positioned literal text leaf', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', { style: styled('position:absolute;left:10%;top:20%;width:30%;'), text: 'Hello' }),
|
||||
],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(unmapped).toEqual([]);
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(placements[0]).toMatchObject({
|
||||
variable: null,
|
||||
text: 'Hello',
|
||||
type: 'text',
|
||||
xPct: 10,
|
||||
yPct: 20,
|
||||
widthPct: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a {{variable}} leaf as a variable block, not literal text', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', { style: styled('position:absolute;left:0%;top:0%;width:40%;'), text: '{{holderName}}' }),
|
||||
],
|
||||
});
|
||||
const { placements } = htmlToPlacements(root);
|
||||
expect(placements[0]).toMatchObject({ variable: 'holderName', text: undefined });
|
||||
});
|
||||
|
||||
it('reads font styling off the element', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', {
|
||||
style: styled(
|
||||
'position:absolute;left:5%;top:5%;width:50%;font-size:18px;font-weight:bold;font-style:italic;text-align:center;color:#0b3d6b;',
|
||||
),
|
||||
text: 'Styled',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { placements } = htmlToPlacements(root);
|
||||
expect(placements[0]).toMatchObject({
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
fontStyle: 'italic',
|
||||
align: 'center',
|
||||
color: '#0b3d6b',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts pt font sizes and numeric font-weight to the canvas convention', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', {
|
||||
style: styled('position:absolute;left:0%;top:0%;width:10%;font-size:12pt;font-weight:700;'),
|
||||
text: 'x',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { placements } = htmlToPlacements(root);
|
||||
expect(placements[0].fontSize).toBe(16); // 12pt * 96/72
|
||||
expect(placements[0].fontWeight).toBe('bold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlacements — images', () => {
|
||||
it('places an absolutely-positioned <img> as an image block', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('img', {
|
||||
style: styled('position:absolute;left:70%;top:5%;width:20%;'),
|
||||
attrs: { src: '{{{sealImage}}}' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { placements } = htmlToPlacements(root);
|
||||
expect(placements[0]).toMatchObject({
|
||||
type: 'image',
|
||||
variable: 'sealImage',
|
||||
xPct: 70,
|
||||
yPct: 5,
|
||||
widthPct: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a full-bleed <img> as the background rather than a block', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('img', {
|
||||
style: styled('position:absolute;inset:0;width:100%;height:100%;'),
|
||||
attrs: { src: 'https://example.org/bg.png' },
|
||||
}),
|
||||
el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'Hi' }),
|
||||
],
|
||||
});
|
||||
const { placements, backgroundUrl } = htmlToPlacements(root);
|
||||
expect(backgroundUrl).toBe('https://example.org/bg.png');
|
||||
expect(placements).toHaveLength(1); // only the text block, not the background image
|
||||
});
|
||||
|
||||
it('recognises class="ema-background" with no inline position at all', () => {
|
||||
// Exactly what compileLayoutToHbs itself emits: the image carries no
|
||||
// inline style — position:absolute;inset:0 lives only in the compiled
|
||||
// document's <style> block, under the .ema-background selector.
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('img', { attrs: { class: 'ema-background', src: 'https://example.org/bg.png' } }),
|
||||
el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'Hi' }),
|
||||
],
|
||||
});
|
||||
const { placements, backgroundUrl, unmapped } = htmlToPlacements(root);
|
||||
expect(backgroundUrl).toBe('https://example.org/bg.png');
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts the mustache variable out of a background image src', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('img', {
|
||||
style: styled('position:absolute;left:0;top:0;width:100%;height:100%;'),
|
||||
attrs: { src: '{{{backgroundUrl}}}' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { backgroundUrl } = htmlToPlacements(root);
|
||||
expect(backgroundUrl).toBe('backgroundUrl');
|
||||
});
|
||||
|
||||
it('reports a non-positioned image as unmapped rather than guessing a spot', () => {
|
||||
const root = el('body', {
|
||||
children: [el('img', { attrs: { src: 'logo.png' } })],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(placements).toEqual([]);
|
||||
expect(unmapped).toHaveLength(1);
|
||||
expect(unmapped[0].reason).toMatch(/not absolutely positioned/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlacements — a positioned wrapper around one child', () => {
|
||||
it('imports a single meaningful child, inheriting the wrapper box', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', {
|
||||
style: styled('position:absolute;left:15%;top:15%;width:25%;'),
|
||||
children: [el('span', { text: '{{approverName}}' })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(unmapped).toEqual([]);
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(placements[0]).toMatchObject({ variable: 'approverName', xPct: 15, yPct: 15, widthPct: 25 });
|
||||
});
|
||||
|
||||
it('reports a positioned wrapper with multiple children as unmapped', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', {
|
||||
style: styled('position:absolute;left:0%;top:0%;width:50%;'),
|
||||
children: [el('span', { text: 'A' }), el('span', { text: 'B' })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(placements).toEqual([]);
|
||||
expect(unmapped).toHaveLength(1);
|
||||
expect(unmapped[0].reason).toMatch(/more than one piece of content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlacements — things it deliberately does not import', () => {
|
||||
it('flags a table', () => {
|
||||
const root = el('body', { children: [el('table', { attrs: { class: 'facts' } })] });
|
||||
const { unmapped } = htmlToPlacements(root);
|
||||
expect(unmapped).toHaveLength(1);
|
||||
expect(unmapped[0].element).toBe('<table class="facts">');
|
||||
expect(unmapped[0].reason).toMatch(/Tables/);
|
||||
});
|
||||
|
||||
it('flags a flex container', () => {
|
||||
const root = el('body', { children: [el('div', { style: styled('display:flex;') })] });
|
||||
const { unmapped } = htmlToPlacements(root);
|
||||
expect(unmapped[0].reason).toMatch(/Flex\/grid/);
|
||||
});
|
||||
|
||||
it('flags a grid container', () => {
|
||||
const root = el('body', { children: [el('div', { style: styled('display:grid;') })] });
|
||||
expect(htmlToPlacements(root).unmapped[0].reason).toMatch(/Flex\/grid/);
|
||||
});
|
||||
|
||||
it('flags a block helper ({{#if}}) rather than treating it as literal text', () => {
|
||||
const root = el('body', {
|
||||
children: [el('div', { text: '{{#if rankLimitation}}{{rankLimitation}}{{/if}}' })],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(placements).toEqual([]);
|
||||
expect(unmapped[0].reason).toMatch(/conditional\/loop/);
|
||||
});
|
||||
|
||||
it('does not flag a plain structural wrapper with no content of its own', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', {
|
||||
attrs: { class: 'wrapper' },
|
||||
children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'x' })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { unmapped } = htmlToPlacements(root);
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports only the first .ema-page and flags the rest', () => {
|
||||
const root = el('body', {
|
||||
children: [
|
||||
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p1' })] }),
|
||||
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p2' })] }),
|
||||
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p3' })] }),
|
||||
],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(placements[0].text).toBe('p1');
|
||||
// One warning per later page, not one for the whole rest of the document.
|
||||
expect(unmapped).toHaveLength(2);
|
||||
expect(unmapped.every((w) => w.reason.match(/later page/))).toBe(true);
|
||||
});
|
||||
|
||||
it('imports everything when the source has no .ema-page wrapper at all', () => {
|
||||
const root = el('body', {
|
||||
children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'plain' })],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(placements).toHaveLength(1);
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlacements — unit conversion', () => {
|
||||
it('converts mm against an explicit page size', () => {
|
||||
const root = el('body', {
|
||||
children: [el('div', { style: styled('position:absolute;left:21mm;top:29.7mm;width:105mm;'), text: 'x' })],
|
||||
});
|
||||
const { placements } = htmlToPlacements(root, { pageWidthMm: 210, pageHeightMm: 297 });
|
||||
expect(placements[0].xPct).toBeCloseTo(10, 5);
|
||||
expect(placements[0].yPct).toBeCloseTo(10, 5);
|
||||
expect(placements[0].widthPct).toBeCloseTo(50, 5);
|
||||
});
|
||||
|
||||
it('resolves width from right when width is absent', () => {
|
||||
const root = el('body', {
|
||||
children: [el('div', { style: styled('position:absolute;left:10%;top:0%;right:20%;'), text: 'x' })],
|
||||
});
|
||||
const { placements } = htmlToPlacements(root);
|
||||
expect(placements[0].widthPct).toBe(70); // 100 - 10 - 20
|
||||
});
|
||||
|
||||
it('treats an unresolvable length (calc/auto) as unmapped, not a guess', () => {
|
||||
const root = el('body', {
|
||||
children: [el('div', { style: styled('position:absolute;left:calc(50% - 10px);top:0%;width:10%;'), text: 'x' })],
|
||||
});
|
||||
const { placements, unmapped } = htmlToPlacements(root);
|
||||
expect(placements).toEqual([]);
|
||||
expect(unmapped[0].reason).toMatch(/plain length/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round trip — importing EMA\'s own compiled canvas output', () => {
|
||||
it('reproduces the same placements a canvas design was compiled from', () => {
|
||||
const original: TemplateFieldPlacement[] = [
|
||||
{ id: 'a', variable: 'holderName', xPct: 10, yPct: 10, widthPct: 40, fontSize: 16, fontWeight: 'bold', fontStyle: 'normal', align: 'left', color: '#111111' },
|
||||
{ id: 'b', variable: null, text: 'Certificate of Competency', xPct: 10, yPct: 30, widthPct: 60, fontSize: 20, fontWeight: 'bold', fontStyle: 'normal', align: 'center', color: '#0b3d6b' },
|
||||
{ id: 'c', variable: 'sealImage', type: 'image', xPct: 70, yPct: 80, widthPct: 15 },
|
||||
];
|
||||
|
||||
const hbs = compileLayoutToHbs({
|
||||
backgroundUrl: 'https://assets.example.org/bg.png',
|
||||
logoUrl: '',
|
||||
logoPlacement: {},
|
||||
fieldPlacements: original,
|
||||
});
|
||||
|
||||
const root = parseFragment(hbs);
|
||||
const { placements, backgroundUrl, unmapped } = htmlToPlacements(root);
|
||||
|
||||
expect(unmapped).toEqual([]);
|
||||
expect(backgroundUrl).toBe('https://assets.example.org/bg.png');
|
||||
expect(placements).toHaveLength(3);
|
||||
|
||||
const byVarOrText = (p: TemplateFieldPlacement) => p.variable ?? p.text;
|
||||
for (const orig of original) {
|
||||
const match = placements.find((p) => byVarOrText(p) === byVarOrText(orig));
|
||||
expect(match, `expected a re-imported block for ${byVarOrText(orig)}`).toBeTruthy();
|
||||
expect(match?.xPct).toBeCloseTo(orig.xPct, 5);
|
||||
expect(match?.yPct).toBeCloseTo(orig.yPct, 5);
|
||||
expect(match?.widthPct).toBeCloseTo(orig.widthPct, 5);
|
||||
if (orig.type === 'image') {
|
||||
expect(match?.type).toBe('image');
|
||||
} else {
|
||||
expect(match?.fontSize).toBe(orig.fontSize);
|
||||
expect(match?.fontWeight).toBe(orig.fontWeight);
|
||||
expect(match?.align).toBe(orig.align);
|
||||
expect(match?.color).toBe(orig.color);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A tiny, dependency-free HTML fragment parser — test-only. This app's test
|
||||
* environment is plain Node with no DOM implementation (see vite.config.mts:
|
||||
* "no jsdom environment... is needed"), so the round-trip test above cannot
|
||||
* use a real `DOMParser`. It only ever needs to parse `compileLayoutToHbs`'s
|
||||
* own well-formed output (self-closing `<img/>`, no comments, `<style>`
|
||||
* treated as opaque text), not arbitrary HTML — the production import path
|
||||
* (`importHtmlSource`) uses the browser's real `DOMParser` instead.
|
||||
*/
|
||||
function parseFragment(html: string): ImportedElement {
|
||||
const VOID_TAGS = new Set(['img', 'br', 'hr', 'meta', 'link', 'input']);
|
||||
const OPAQUE_TAGS = new Set(['style', 'script']);
|
||||
let i = 0;
|
||||
|
||||
function parseAttrs(tagSrc: string): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /([a-zA-Z-]+)\s*=\s*"([^"]*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(tagSrc))) attrs[m[1]] = m[2];
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every node up to (and consuming) `</untilTag>`, or end of input for
|
||||
* the synthetic document root (`untilTag === null`). Text runs become the
|
||||
* *current* element's own text (the caller passes its own tag as
|
||||
* `untilTag`); a nested element's opening tag starts a fresh recursive call
|
||||
* so its own text is captured the same way, one level down.
|
||||
*/
|
||||
function parseUntil(untilTag: string | null): { text: string; children: ImportedElement[] } {
|
||||
const children: ImportedElement[] = [];
|
||||
let text = '';
|
||||
|
||||
while (i < html.length) {
|
||||
if (html[i] !== '<') {
|
||||
text += html[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (html.startsWith('<!', i)) {
|
||||
// Comment or doctype — skip to '>'.
|
||||
i = html.indexOf('>', i) + 1;
|
||||
continue;
|
||||
}
|
||||
if (html.startsWith('</', i)) {
|
||||
const end = html.indexOf('>', i);
|
||||
const closingTag = html.slice(i + 2, end).trim().toLowerCase();
|
||||
i = end + 1;
|
||||
if (closingTag === untilTag) return { text, children };
|
||||
continue; // mismatched close (shouldn't happen in well-formed input) — ignore
|
||||
}
|
||||
|
||||
const end = html.indexOf('>', i);
|
||||
const tagSrc = html.slice(i + 1, end);
|
||||
const selfClosing = tagSrc.trim().endsWith('/');
|
||||
const tag = (tagSrc.match(/^([a-zA-Z0-9]+)/)?.[1] ?? '').toLowerCase();
|
||||
const attrs = parseAttrs(tagSrc);
|
||||
i = end + 1;
|
||||
|
||||
if (OPAQUE_TAGS.has(tag)) {
|
||||
const close = `</${tag}>`;
|
||||
const closeAt = html.indexOf(close, i);
|
||||
i = closeAt === -1 ? html.length : closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selfClosing || VOID_TAGS.has(tag)) {
|
||||
children.push({ tag, style: parseInlineStyle(attrs.style), attrs, text: '', children: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const nested = parseUntil(tag);
|
||||
children.push({ tag, style: parseInlineStyle(attrs.style), attrs, text: nested.text, children: nested.children });
|
||||
}
|
||||
return { text, children };
|
||||
}
|
||||
|
||||
// `compileLayoutToHbs` always emits a full `<!doctype html><html>...`
|
||||
// document — wrap the parsed top-level nodes in a synthetic root so the
|
||||
// caller gets one element back, same shape `document.documentElement`
|
||||
// would have been.
|
||||
const { children } = parseUntil(null);
|
||||
return { tag: 'root', style: {}, attrs: {}, text: '', children };
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import type { TemplateFieldPlacement } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Imports a hand-authored Handlebars/HTML certificate into canvas blocks.
|
||||
*
|
||||
* This is necessarily best-effort. The canvas's own model (`TemplateFieldPlacement`)
|
||||
* is a flat list of absolutely-positioned boxes — see `layout-compiler.ts`, the
|
||||
* only thing that has ever produced one. Arbitrary HTML has no such
|
||||
* constraint: a `<table>`, a flex/grid layout, a `{{#if}}` branch, or a
|
||||
* relatively-flowed paragraph has no single `left/top/width` that means the
|
||||
* same thing once lifted out of its surrounding markup. Rather than silently
|
||||
* dropping those, every element the importer cannot place as a clean box is
|
||||
* reported back so the author knows to finish it by hand (in the HTML source
|
||||
* tab, or by rebuilding it as a block on the canvas).
|
||||
*
|
||||
* What DOES import cleanly: exactly the shape `compileLayoutToHbs` itself
|
||||
* emits — an element with inline `position:absolute; left; top; width` (in
|
||||
* %, mm, in, or px, resolved against the declared page size) carrying either
|
||||
* a text/`{{variable}}` node or an `<img>`. Every bundled certificate design
|
||||
* in this codebase is authored that way for exactly this reason — importing
|
||||
* one and re-editing it on the canvas is a fully lossless round trip.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A minimal, DOM-library-agnostic element shape.
|
||||
//
|
||||
// The real adapter (`domElementToImported`, at the bottom) wraps a browser
|
||||
// `Element`; tests build this shape directly, so the extraction logic itself
|
||||
// needs no DOM implementation and runs the same in Node as in the browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ImportedElement {
|
||||
/** Lowercase tag name, e.g. "div", "img". */
|
||||
tag: string;
|
||||
/** Inline `style="..."` attribute, already split into declarations. */
|
||||
style: Record<string, string>;
|
||||
attrs: Record<string, string>;
|
||||
/**
|
||||
* This element's own text, excluding any child elements' text — i.e. what
|
||||
* a DOM adapter would build from the element's direct Text node children
|
||||
* only, not `element.textContent` (which would also pull in every
|
||||
* descendant's text and double-count it once children are walked too).
|
||||
*/
|
||||
text: string;
|
||||
children: ImportedElement[];
|
||||
}
|
||||
|
||||
export interface ImportWarning {
|
||||
/** Where the problem is, for the report: a short tag/class/id descriptor. */
|
||||
element: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
placements: TemplateFieldPlacement[];
|
||||
/** First plain `<img>` covering (close to) the whole page — see `isBackgroundImage`. */
|
||||
backgroundUrl: string | null;
|
||||
unmapped: ImportWarning[];
|
||||
}
|
||||
|
||||
export interface ImportOptions {
|
||||
/** The page box these percentages are resolved against. Defaults to A4 portrait mm. */
|
||||
pageWidthMm?: number;
|
||||
pageHeightMm?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_WIDTH_MM = 210;
|
||||
const DEFAULT_PAGE_HEIGHT_MM = 297;
|
||||
|
||||
/** Structural tags that never become a block themselves — only their content does. */
|
||||
const CONTAINER_TAGS = new Set(['html', 'body', 'head', 'style', 'script']);
|
||||
|
||||
/** Constructs a stable-enough id; collisions are harmless (React key + selection only). */
|
||||
function blockId(index: number): string {
|
||||
return `import_${index}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit conversion — every length the importer accepts, resolved to a percent
|
||||
// of the declared page box. `%` values pass through unchanged (already what
|
||||
// the canvas stores); everything else is a physical or pixel length assumed
|
||||
// at 96 CSS px per inch, the same assumption a browser makes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PX_PER_IN = 96;
|
||||
const MM_PER_IN = 25.4;
|
||||
|
||||
function toMm(value: number, unit: string): number | null {
|
||||
switch (unit) {
|
||||
case 'mm':
|
||||
return value;
|
||||
case 'cm':
|
||||
return value * 10;
|
||||
case 'in':
|
||||
return value * MM_PER_IN;
|
||||
case 'px':
|
||||
return (value / PX_PER_IN) * MM_PER_IN;
|
||||
case 'pt':
|
||||
return ((value / 72) * MM_PER_IN);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a CSS length against one page dimension, as a percent of it.
|
||||
* `null` for anything not expressible as a plain length (a %, unitless
|
||||
* number, `calc()`, `auto`, a CSS variable) — the caller decides what that
|
||||
* means for the element carrying it.
|
||||
*/
|
||||
function lengthToPct(raw: string | undefined, pageDimensionMm: number): number | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
const pctMatch = trimmed.match(/^(-?[\d.]+)%$/);
|
||||
if (pctMatch) return Number(pctMatch[1]);
|
||||
const lengthMatch = trimmed.match(/^(-?[\d.]+)(mm|cm|in|px|pt)$/);
|
||||
if (!lengthMatch) return null;
|
||||
const mm = toMm(Number(lengthMatch[1]), lengthMatch[2]);
|
||||
if (mm === null || pageDimensionMm <= 0) return null;
|
||||
return (mm / pageDimensionMm) * 100;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline style parsing — a plain `key:value;key:value` splitter. No CSS
|
||||
// library needed: this only ever sees a `style="..."` attribute value, never
|
||||
// a full stylesheet (selectors, media queries, `<style>` blocks are out of
|
||||
// scope for the same reason a `<table>` is — see the module doc).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseInlineStyle(styleAttr: string | null | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!styleAttr) return out;
|
||||
for (const decl of styleAttr.split(';')) {
|
||||
const colon = decl.indexOf(':');
|
||||
if (colon === -1) continue;
|
||||
const key = decl.slice(0, colon).trim().toLowerCase();
|
||||
const value = decl.slice(colon + 1).trim();
|
||||
if (key && value) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mustache extraction from a text node — `{{{var}}}` (unescaped, the image
|
||||
// convention) and `{{var}}` (escaped text/variable). A block helper opening
|
||||
// or closing tag (`{{#if x}}`, `{{/if}}`, `{{else}}`) is deliberately not
|
||||
// matched here: those don't name a single value to place, so they surface as
|
||||
// literal, unmapped text instead of being silently swallowed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TRIPLE_MUSTACHE = /^\{\{\{\s*([\w.]+)\s*\}\}\}$/;
|
||||
const DOUBLE_MUSTACHE = /^\{\{\s*([\w.]+)\s*\}\}$/;
|
||||
const BLOCK_HELPER = /\{\{[#/]|\{\{\s*else\b/;
|
||||
|
||||
function extractVariable(text: string): { variable: string; raw: boolean } | null {
|
||||
const trimmed = text.trim();
|
||||
const triple = trimmed.match(TRIPLE_MUSTACHE);
|
||||
if (triple) return { variable: triple[1], raw: true };
|
||||
const double = trimmed.match(DOUBLE_MUSTACHE);
|
||||
if (double) return { variable: double[1], raw: false };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry — is this element itself "absolutely placeable" as one box?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Box {
|
||||
xPct: number;
|
||||
yPct: number;
|
||||
widthPct: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box an element occupies, or null when it isn't expressed as a length
|
||||
* the importer understands (a % or physical unit) against `left`/`top` and
|
||||
* either `width` or `right`. `position` is checked by the caller — this only
|
||||
* resolves the numbers once that gate has already passed.
|
||||
*/
|
||||
function boxFor(style: Record<string, string>, pageWidthMm: number, pageHeightMm: number): Box | null {
|
||||
const left = lengthToPct(style.left, pageWidthMm);
|
||||
const top = lengthToPct(style.top, pageHeightMm);
|
||||
if (left === null || top === null) return null;
|
||||
|
||||
let width = lengthToPct(style.width, pageWidthMm);
|
||||
if (width === null && style.right) {
|
||||
const right = lengthToPct(style.right, pageWidthMm);
|
||||
if (right !== null) width = 100 - left - right;
|
||||
}
|
||||
if (width === null) return null;
|
||||
|
||||
return { xPct: left, yPct: top, widthPct: width };
|
||||
}
|
||||
|
||||
/** `font-size` resolved to px — the unit the canvas itself stores. */
|
||||
function fontSizePx(style: Record<string, string>): number | undefined {
|
||||
const raw = style['font-size'];
|
||||
if (!raw) return undefined;
|
||||
const match = raw.trim().match(/^([\d.]+)(px|pt)?$/);
|
||||
if (!match) return undefined;
|
||||
const n = Number(match[1]);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return match[2] === 'pt' ? Math.round(n * (96 / 72)) : Math.round(n);
|
||||
}
|
||||
|
||||
function fontWeight(style: Record<string, string>): 'normal' | 'bold' | undefined {
|
||||
const raw = style['font-weight']?.trim().toLowerCase();
|
||||
if (!raw) return undefined;
|
||||
if (raw === 'bold' || raw === 'bolder') return 'bold';
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) return n >= 600 ? 'bold' : 'normal';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function fontStyle(style: Record<string, string>): 'normal' | 'italic' | undefined {
|
||||
const raw = style['font-style']?.trim().toLowerCase();
|
||||
return raw === 'italic' || raw === 'oblique' ? 'italic' : undefined;
|
||||
}
|
||||
|
||||
function textAlign(style: Record<string, string>): TemplateFieldPlacement['align'] | undefined {
|
||||
const raw = style['text-align']?.trim().toLowerCase();
|
||||
if (raw === 'left' || raw === 'center' || raw === 'right' || raw === 'justify') return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A short, human-readable label for a warning — tag, id, first class, in that order of usefulness. */
|
||||
function describe(el: ImportedElement): string {
|
||||
if (el.attrs.id) return `<${el.tag} id="${el.attrs.id}">`;
|
||||
const cls = el.attrs.class?.trim().split(/\s+/)[0];
|
||||
if (cls) return `<${el.tag} class="${cls}">`;
|
||||
return `<${el.tag}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain `<img>` is treated as the page background — rather than a block —
|
||||
* when it covers essentially the whole page: `inset:0` / `position:absolute`
|
||||
* with no meaningful left/top/width of its own, or explicit 100%-ish
|
||||
* width+height. This is the same role `compileLayoutToHbs` gives
|
||||
* `.ema-background`, so importing one of EMA's own compiled designs finds it.
|
||||
*
|
||||
* `compileLayoutToHbs` itself gives that image no inline style at all — its
|
||||
* `position:absolute; inset:0` comes entirely from the `.ema-background`
|
||||
* class in the document's `<style>` block, which this importer does not
|
||||
* (and, per the module doc, deliberately does not) resolve selectors
|
||||
* against. `class="ema-background"` is checked first and directly for
|
||||
* exactly that reason: it is the one CSS-class signal worth trusting, since
|
||||
* it is the compiler's own marker for this element's role, not a generic
|
||||
* stylesheet rule an importer would have to interpret.
|
||||
*/
|
||||
function isBackgroundImage(attrs: Record<string, string>, style: Record<string, string>): boolean {
|
||||
if (attrs.class?.trim().split(/\s+/).includes('ema-background')) return true;
|
||||
|
||||
const position = style.position?.trim().toLowerCase();
|
||||
if (position !== 'absolute' && position !== 'fixed') return false;
|
||||
const inset = style.inset?.trim().replace(/\s+/g, ' ');
|
||||
if (inset === '0' || inset === '0 0 0 0' || inset === '0px 0px 0px 0px') return true;
|
||||
const width = style.width?.trim();
|
||||
const height = style.height?.trim();
|
||||
const noOffset = (!style.left || style.left.trim() === '0' || style.left.trim() === '0px')
|
||||
&& (!style.top || style.top.trim() === '0' || style.top.trim() === '0px');
|
||||
return noOffset && (width === '100%' || height === '100%');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The walk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** True for an element that marks a page boundary — `compileLayoutToHbs`'s own `.ema-page` wrapper, one per sheet. */
|
||||
function isPageWrapper(el: ImportedElement): boolean {
|
||||
return el.attrs.class?.trim().split(/\s+/).includes('ema-page') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a parsed HTML/Handlebars document into canvas blocks.
|
||||
*
|
||||
* Only the first page's worth of content is imported — a booklet design
|
||||
* (multiple `.ema-page` sections, as `compileLayoutToHbs` emits one per
|
||||
* sheet) is reported as unmapped for every page after the first rather than
|
||||
* guessed at, since nothing before the canvas existed carries a page
|
||||
* boundary the importer can trust for markup that doesn't use that class.
|
||||
*/
|
||||
export function htmlToPlacements(root: ImportedElement, options: ImportOptions = {}): ImportResult {
|
||||
const pageWidthMm = options.pageWidthMm ?? DEFAULT_PAGE_WIDTH_MM;
|
||||
const pageHeightMm = options.pageHeightMm ?? DEFAULT_PAGE_HEIGHT_MM;
|
||||
|
||||
const placements: TemplateFieldPlacement[] = [];
|
||||
const unmapped: ImportWarning[] = [];
|
||||
let backgroundUrl: string | null = null;
|
||||
let index = 0;
|
||||
let sawFirstPageWrapper = false;
|
||||
|
||||
function walk(el: ImportedElement) {
|
||||
if (CONTAINER_TAGS.has(el.tag)) {
|
||||
for (const child of el.children) walk(child);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every `.ema-page` after the first is a later sheet in a booklet — out
|
||||
// of scope, see the doc comment above. A source with no `.ema-page` at
|
||||
// all (every hand-written template except the canvas's own output) has
|
||||
// nothing to gate here, and walks in full.
|
||||
if (isPageWrapper(el)) {
|
||||
if (sawFirstPageWrapper) {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'A later page in this booklet was not imported — the canvas imports one page at a time.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
sawFirstPageWrapper = true;
|
||||
for (const child of el.children) walk(child);
|
||||
return;
|
||||
}
|
||||
|
||||
const style = el.style;
|
||||
const position = style.position?.trim().toLowerCase();
|
||||
|
||||
if (el.tag === 'img') {
|
||||
const src = el.attrs.src ?? '';
|
||||
if (isBackgroundImage(el.attrs, style)) {
|
||||
if (!backgroundUrl) backgroundUrl = mustacheImageSource(src) ?? src;
|
||||
else {
|
||||
unmapped.push({ element: describe(el), reason: 'A second full-page image was skipped — only one background is supported.' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (position !== 'absolute' && position !== 'fixed') {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Image is not absolutely positioned (no position:absolute with left/top), so it has no fixed spot to place on the canvas.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const box = boxFor(style, pageWidthMm, pageHeightMm);
|
||||
if (!box) {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Image position/width is not a plain length (%, mm, in, px or pt) the canvas can place.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const variable = mustacheImageSource(src);
|
||||
placements.push({
|
||||
id: blockId(index++),
|
||||
variable,
|
||||
type: 'image',
|
||||
xPct: box.xPct,
|
||||
yPct: box.yPct,
|
||||
widthPct: box.widthPct,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (position === 'absolute' || position === 'fixed') {
|
||||
const box = boxFor(style, pageWidthMm, pageHeightMm);
|
||||
if (!box) {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Position/width is not a plain length (%, mm, in, px or pt) the canvas can place.',
|
||||
});
|
||||
// Still walk children — a positioned wrapper around plain
|
||||
// (non-positioned) content is common hand-authored markup, and its
|
||||
// children might still resolve their own positions.
|
||||
for (const child of el.children) walk(child);
|
||||
return;
|
||||
}
|
||||
|
||||
// This element's own direct text, if any — a leaf like
|
||||
// `<div style="position:absolute;...">{{holderName}}</div>`. `children`
|
||||
// only ever holds real elements (text is folded into `.text` by the
|
||||
// adapter/fixtures), so "no element children" is just an empty array.
|
||||
const ownText = el.text.trim();
|
||||
const hasElementChildren = el.children.length > 0;
|
||||
|
||||
if (ownText && !hasElementChildren) {
|
||||
placeText(el, ownText, box);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ownText && !hasElementChildren) {
|
||||
// An empty positioned box — nothing to place, and nothing to warn
|
||||
// about either (e.g. a spacer div).
|
||||
return;
|
||||
}
|
||||
|
||||
// A positioned wrapper with element children (not a single text leaf):
|
||||
// resolvable structure only if it holds exactly one meaningful child,
|
||||
// which inherits the wrapper's box — a `<div style="position:absolute;...">
|
||||
// <span>{{x}}</span></div>` pattern real templates sometimes use for a
|
||||
// style hook. More than one child means real internal layout the
|
||||
// canvas's flat model cannot represent.
|
||||
const meaningfulChildren = el.children.filter(
|
||||
(c) => c.text.trim() || c.children.length || c.tag === 'img',
|
||||
);
|
||||
if (meaningfulChildren.length === 1 && !ownText) {
|
||||
const child = meaningfulChildren[0];
|
||||
const childText = child.text.trim();
|
||||
const childHasElementChildren = child.children.length > 0;
|
||||
if (child.tag === 'img') {
|
||||
const src = child.attrs.src ?? '';
|
||||
placements.push({
|
||||
id: blockId(index++),
|
||||
variable: mustacheImageSource(src),
|
||||
type: 'image',
|
||||
xPct: box.xPct,
|
||||
yPct: box.yPct,
|
||||
widthPct: box.widthPct,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (childText && !childHasElementChildren) {
|
||||
placeText({ ...child, style: { ...style, ...child.style } }, childText, box);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Positioned element holds more than one piece of content (a layout, not a single field) — split it into separate blocks by hand.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Not itself positioned — a plain wrapper (e.g. `<body>`'s children
|
||||
// before the canvas's own `.ema-page` div). Keep walking; only elements
|
||||
// that are *never* going to resolve a box report a warning, so a
|
||||
// structural `<div>` around everything doesn't itself get flagged.
|
||||
if (el.tag === 'table') {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Tables have no equivalent on the canvas (rows/columns cannot be expressed as fixed boxes) — recreate its cells as individual text blocks, or keep this design in the HTML source tab.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isFlexOrGrid(style)) {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Flex/grid layout has no equivalent on the canvas — its children\'s positions depend on each other rather than the page, so they were not imported.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (BLOCK_HELPER.test(el.text)) {
|
||||
unmapped.push({
|
||||
element: describe(el),
|
||||
reason: 'Contains a conditional/loop ({{#if}}, {{#each}}, {{else}}) — the canvas can only place a fixed value, not branching logic.',
|
||||
});
|
||||
}
|
||||
|
||||
for (const child of el.children) walk(child);
|
||||
}
|
||||
|
||||
function placeText(el: ImportedElement, ownText: string, box: Box) {
|
||||
const mustache = extractVariable(ownText);
|
||||
placements.push({
|
||||
id: blockId(index++),
|
||||
variable: mustache?.variable ?? null,
|
||||
text: mustache ? undefined : ownText,
|
||||
type: 'text',
|
||||
xPct: box.xPct,
|
||||
yPct: box.yPct,
|
||||
widthPct: box.widthPct,
|
||||
fontSize: fontSizePx(el.style),
|
||||
fontWeight: fontWeight(el.style),
|
||||
fontStyle: fontStyle(el.style),
|
||||
align: textAlign(el.style),
|
||||
color: el.style.color?.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
walk(root);
|
||||
|
||||
return { placements, backgroundUrl, unmapped };
|
||||
}
|
||||
|
||||
/** `{{{variable}}}` inside an `src` (the image convention) → the bare variable name, or null for a literal URL/data URI. */
|
||||
function mustacheImageSource(src: string): string | null {
|
||||
const trimmed = src.trim();
|
||||
const triple = trimmed.match(TRIPLE_MUSTACHE);
|
||||
if (triple) return triple[1];
|
||||
const double = trimmed.match(DOUBLE_MUSTACHE);
|
||||
if (double) return double[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFlexOrGrid(style: Record<string, string>): boolean {
|
||||
const display = style.display?.trim().toLowerCase();
|
||||
return display === 'flex' || display === 'inline-flex' || display === 'grid' || display === 'inline-grid';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browser adapter — the only part of this module that touches a real DOM.
|
||||
// Kept tiny and separate so the extraction logic above stays unit-testable
|
||||
// with plain object fixtures, matching this app's "pure helpers are tested,
|
||||
// components/DOM code are not" convention (see vite.config.mts).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function domElementToImported(el: Element): ImportedElement {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
|
||||
|
||||
let ownText = '';
|
||||
const children: ImportedElement[] = [];
|
||||
for (const node of Array.from(el.childNodes)) {
|
||||
if (node.nodeType === 3 /* Node.TEXT_NODE */) {
|
||||
ownText += node.textContent ?? '';
|
||||
} else if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
|
||||
children.push(domElementToImported(node as Element));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
style: parseInlineStyle(el.getAttribute('style')),
|
||||
attrs,
|
||||
text: ownText,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an HTML/Handlebars source string in the browser and extracts canvas
|
||||
* blocks from it. `pageWidthMm`/`pageHeightMm` should match the design's own
|
||||
* page size (the designer's current width/height fields) so `%`-based
|
||||
* lengths — which every EMA-authored template already uses — resolve
|
||||
* exactly, and physical units (mm/in/px) resolve consistently with it.
|
||||
*/
|
||||
export function importHtmlSource(html: string, options: ImportOptions = {}): ImportResult {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const root = domElementToImported(doc.documentElement);
|
||||
return htmlToPlacements(root, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The designer's page-size fields (`landscape`, and optionally an explicit
|
||||
* `width`/`height` CSS length, e.g. `"4.92in"` for the Seaman Book) resolved
|
||||
* to millimetres, orientation applied. A4 portrait when no explicit size is
|
||||
* set — the same default the designer itself falls back to (`pageOptionsFor`
|
||||
* in `designer.ts`).
|
||||
*/
|
||||
export function pageDimensionsMm(
|
||||
landscape: boolean,
|
||||
pageWidth?: string,
|
||||
pageHeight?: string,
|
||||
): { pageWidthMm: number; pageHeightMm: number } {
|
||||
const explicitW = toMmFromLength(pageWidth);
|
||||
const explicitH = toMmFromLength(pageHeight);
|
||||
let widthMm = explicitW ?? DEFAULT_PAGE_WIDTH_MM;
|
||||
let heightMm = explicitH ?? DEFAULT_PAGE_HEIGHT_MM;
|
||||
// Portrait is the box's natural reading; landscape swaps it, matching how
|
||||
// `TemplateCanvas` and Puppeteer's own `landscape` page option both treat
|
||||
// an explicit width/height pair (as a portrait box, rotated for print).
|
||||
if (landscape && widthMm < heightMm) [widthMm, heightMm] = [heightMm, widthMm];
|
||||
if (!landscape && widthMm > heightMm) [widthMm, heightMm] = [heightMm, widthMm];
|
||||
return { pageWidthMm: widthMm, pageHeightMm: heightMm };
|
||||
}
|
||||
|
||||
/** A CSS length string (e.g. "4.92in", "125mm") to millimetres, or null when absent/unparseable. */
|
||||
function toMmFromLength(value: string | undefined): number | null {
|
||||
if (!value?.trim()) return null;
|
||||
const match = value.trim().match(/^([\d.]+)(mm|cm|in|px|pt)?$/);
|
||||
if (!match) return null;
|
||||
const n = Number(match[1]);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return toMm(n, match[2] ?? 'mm');
|
||||
}
|
||||
@@ -158,6 +158,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Replaces the draft's blocks wholesale — how a one-time HTML-to-canvas
|
||||
* conversion hands off its result. Distinct from `addBlock`/`updateBlock`,
|
||||
* which grow or edit the canvas one block at a time; this is the only
|
||||
* caller that sets the whole array in one step.
|
||||
*/
|
||||
const replaceAllBlocks = useCallback((blocks: TemplateFieldPlacement[]) => {
|
||||
setPlacements(blocks);
|
||||
setSelectedBlockId(null);
|
||||
setCurrentPage(0);
|
||||
setExtraPages(0);
|
||||
}, []);
|
||||
|
||||
const deleteBlock = useCallback((id: string) => {
|
||||
setPlacements((prev) => prev.filter((block) => block.id !== id));
|
||||
setSelectedBlockId((current) => (current === id ? null : current));
|
||||
@@ -213,6 +226,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
|
||||
addBlock,
|
||||
updateBlock,
|
||||
deleteBlock,
|
||||
replaceAllBlocks,
|
||||
editorRef,
|
||||
isPublished,
|
||||
dirty,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { EmptyState, ErrorState, ModalFooter, PageHeader, PdfPreviewModal } from
|
||||
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
|
||||
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
|
||||
import { DesignerToolbar } from '../components/DesignerToolbar';
|
||||
import { ImportHtmlModal } from '../components/ImportHtmlModal';
|
||||
import { NewVersionModal } from '../components/NewVersionModal';
|
||||
import { TemplateActionBar } from '../components/TemplateActionBar';
|
||||
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
|
||||
@@ -44,6 +45,7 @@ import { TemplateVariableList } from '../components/TemplateVariableList';
|
||||
import { TemplateVersionList } from '../components/TemplateVersionList';
|
||||
import { pageOptionsFor } from '../config/designer';
|
||||
import { compileLayoutToHbs } from '../config/layout-compiler';
|
||||
import { importHtmlSource, pageDimensionsMm, type ImportResult } from '../config/html-import';
|
||||
import { useDesignerActions } from '../hooks/useDesignerActions';
|
||||
import { useTemplateDraft } from '../hooks/useTemplateDraft';
|
||||
import { useTemplatePreview } from '../hooks/useTemplatePreview';
|
||||
@@ -101,6 +103,7 @@ export function CertificateDesignerPage() {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
|
||||
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
|
||||
|
||||
@@ -140,6 +143,25 @@ export function CertificateDesignerPage() {
|
||||
setNewOpen(true);
|
||||
}
|
||||
|
||||
/** Parses the current HTML source and opens the report before touching the canvas. */
|
||||
function importToCanvas() {
|
||||
const { pageWidthMm, pageHeightMm } = pageDimensionsMm(
|
||||
draft.landscape,
|
||||
draft.pageWidth,
|
||||
draft.pageHeight,
|
||||
);
|
||||
setImportResult(importHtmlSource(draft.source, { pageWidthMm, pageHeightMm }));
|
||||
}
|
||||
|
||||
/** Replaces the canvas's blocks/background with what the import found. */
|
||||
function applyImport() {
|
||||
if (!importResult) return;
|
||||
draft.setPlacements(importResult.placements);
|
||||
if (importResult.backgroundUrl) draft.setBackgroundUrl(importResult.backgroundUrl);
|
||||
setImportResult(null);
|
||||
setMode('canvas');
|
||||
}
|
||||
|
||||
const editingLocked = !canEdit || draft.isPublished;
|
||||
|
||||
return (
|
||||
@@ -259,6 +281,16 @@ export function CertificateDesignerPage() {
|
||||
|
||||
<Tabs.Panel value="canvas" pt="sm">
|
||||
<Stack gap="sm">
|
||||
{!draft.usesCanvas && (
|
||||
<Paper withBorder p="xs" bg="var(--mantine-color-yellow-light)">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'designer.sourceOwnsCanvas',
|
||||
'This design is hand-written HTML (see the HTML source tab) — the canvas below is empty because it has no blocks, not because the design is empty. Adding a block here starts a canvas layout that will replace the hand-written HTML on save.',
|
||||
)}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
<TextInput
|
||||
label={t('designer.name', 'Version name')}
|
||||
value={draft.name}
|
||||
@@ -313,6 +345,7 @@ export function CertificateDesignerPage() {
|
||||
editorRef={draft.editorRef}
|
||||
disabled={editingLocked}
|
||||
isPublished={draft.isPublished}
|
||||
onImportToCanvas={importToCanvas}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
@@ -463,6 +496,13 @@ export function CertificateDesignerPage() {
|
||||
url={previewUrl ?? ''}
|
||||
title={t('designer.preview', 'Preview')}
|
||||
/>
|
||||
|
||||
<ImportHtmlModal
|
||||
opened={Boolean(importResult)}
|
||||
onClose={() => setImportResult(null)}
|
||||
result={importResult}
|
||||
onApply={applyImport}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,17 @@ export function CertificationPage() {
|
||||
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
|
||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||
const { data: rankRes } = useGetRanksQuery();
|
||||
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||
// Every CoC rank exists three times: once per STCW tier (Above/Below,
|
||||
// properly suffixed — see `rankTierSuffix` server-side) plus one untiered
|
||||
// row kept only so a licence issued before the tier split still resolves
|
||||
// to a catalogue entry. That legacy row's name carries no tier and reads
|
||||
// as a plain duplicate ("Chief Mate" next to "Chief Mate — Above/Below"),
|
||||
// so it is excluded here — a new certification should always be scoped to
|
||||
// a real, disambiguated rung. CoP ranks have no tier at all and are
|
||||
// untouched by this filter.
|
||||
const rankOptions = (rankRes?.items ?? [])
|
||||
.filter((r) => r.certificateCategory !== 'COC' || r.tier !== null)
|
||||
.map((r) => ({ value: r.key, label: localized(r.name) }));
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
|
||||
Reference in New Issue
Block a user