diff --git a/apps/www/app/_assets/templates/index.ts b/apps/www/app/_assets/templates/index.ts new file mode 100644 index 0000000..5f9ba26 --- /dev/null +++ b/apps/www/app/_assets/templates/index.ts @@ -0,0 +1,48 @@ +/** + * SVG templates for the image generator. Add a template by dropping the + * Illustrator export next to this file (kebab-case file name) and listing it + * here. Templates are loaded on demand – an export with embedded images can + * be megabytes, and it should not weigh down the page until chosen. + * + * In Illustrator: every text object becomes an editable field, and a shape + * whose layer name ends in `[image]` (e.g. "Bilde [image]") becomes an image + * upload that fills the shape. Layer names are used as field labels. The name + * may sit on the object itself or on the layer/group that wraps only it. + * + * Illustrator does not export text alignment, so mark it in the layer name: + * `Navn [center]` or `Dato [right]`. Text without a marker is left-aligned. + */ +export type ImageTemplate = { + /** Shown in the template chooser and used as the download file name. */ + name: string; + /** Loads the SVG markup. */ + load: () => Promise; +}; + +const raw = (loader: () => Promise<{ default: string }>) => () => + loader().then((module) => module.default); + +export const imageTemplates = { + 'test-template': { + name: 'Testmal (16:9)', + load: raw(() => import('./test-template.svg?raw')), + }, + 'test-template-bilde': { + name: 'Testmal med bilde', + load: raw(() => import('./test-template-bilde.svg?raw')), + }, + 'presentasjon-av-to-innledere': { + name: 'Presentasjon av to innledere', + load: raw(() => import('./presentasjon-av-to-innledere.svg?raw')), + }, + 'presentasjon-av-to-innledere-to': { + name: 'Min min nye mal', + load: raw(() => import('./presentasjon-av-to-innledere.svg?raw')), + }, +} satisfies Record; + +export type ImageTemplateId = keyof typeof imageTemplates; + +export const imageTemplateIds = Object.keys( + imageTemplates, +) as ImageTemplateId[]; diff --git a/apps/www/app/_assets/templates/presentasjon-av-to-innledere.svg b/apps/www/app/_assets/templates/presentasjon-av-to-innledere.svg new file mode 100644 index 0000000..101b097 --- /dev/null +++ b/apps/www/app/_assets/templates/presentasjon-av-to-innledere.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Balansekunst + + + Clarion Hotel The + + + + 16.17. juni 2026 + + + + + Møt dem på + + + Konf + Tittel + + Navn + + Tittel + + Navn + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/www/app/_assets/templates/test-template-bilde.svg b/apps/www/app/_assets/templates/test-template-bilde.svg new file mode 100644 index 0000000..0459d80 --- /dev/null +++ b/apps/www/app/_assets/templates/test-template-bilde.svg @@ -0,0 +1,8 @@ + + + + + + navn + firma + \ No newline at end of file diff --git a/apps/www/app/_assets/templates/test-template.svg b/apps/www/app/_assets/templates/test-template.svg new file mode 100644 index 0000000..de959e2 --- /dev/null +++ b/apps/www/app/_assets/templates/test-template.svg @@ -0,0 +1,8 @@ + + + + + Tekst 3 + Tekst 2 + Tekst 1 + \ No newline at end of file diff --git a/apps/www/app/_components/illustration-library/download-illustration.ts b/apps/www/app/_components/illustration-library/download-illustration.ts index 31e4f11..456fa9e 100644 --- a/apps/www/app/_components/illustration-library/download-illustration.ts +++ b/apps/www/app/_components/illustration-library/download-illustration.ts @@ -1,11 +1,18 @@ /** - * Browser-side export of an illustration: download as SVG, or rasterised to - * PNG/WebP via a canvas, and copy as a PNG image for apps like PowerPoint that - * paste SVG markup as text. Rasters are rendered with a transparent background - * and their longest side at `RASTER_MAX_SIDE` pixels. + * Export of an illustration: download as SVG, PNG or WebP, or copy as a PNG + * image. Rasters are rendered with a transparent background and their longest + * side at `RASTER_MAX_SIDE` pixels. */ +import { + copySvgAsImage, + rasterizeSvg, + SVG_MIME, + saveBlob, + viewBoxSize, +} from '~/_utils/svg-export'; + const formats = { - svg: { label: 'SVG', mime: 'image/svg+xml' }, + svg: { label: 'SVG', mime: SVG_MIME }, png: { label: 'PNG', mime: 'image/png' }, webp: { label: 'WebP', mime: 'image/webp' }, } as const; @@ -18,101 +25,19 @@ export const downloadFormats = (Object.keys(formats) as DownloadFormat[]).map( const RASTER_MAX_SIDE = 2048; -const saveBlob = (blob: Blob, fileName: string) => { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - link.remove(); - // Give the browser a moment to start the download before revoking. - setTimeout(() => URL.revokeObjectURL(url), 10_000); -}; - /** Pixel size for the raster, from the SVG's viewBox aspect ratio. */ const rasterSize = (viewBox?: string) => { - const [, , width, height] = (viewBox ?? '') - .trim() - .split(/[\s,]+/) - .map(Number); - if (!width || !height) { - return { width: RASTER_MAX_SIDE, height: RASTER_MAX_SIDE }; - } - const scale = RASTER_MAX_SIDE / Math.max(width, height); + const size = viewBoxSize(viewBox); + if (!size) return { width: RASTER_MAX_SIDE, height: RASTER_MAX_SIDE }; + const scale = RASTER_MAX_SIDE / Math.max(size.width, size.height); return { - width: Math.round(width * scale), - height: Math.round(height * scale), + width: Math.round(size.width * scale), + height: Math.round(size.height * scale), }; }; -const loadImage = (svg: string) => - new Promise((resolve, reject) => { - const url = URL.createObjectURL( - new Blob([svg], { type: formats.svg.mime }), - ); - const image = new Image(); - image.onload = () => { - URL.revokeObjectURL(url); - resolve(image); - }; - image.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Kunne ikke lese SVG-en.')); - }; - image.src = url; - }); - -const rasterize = async ( - svg: string, - viewBox: string | undefined, - format: Exclude, -): Promise => { - const { width, height } = rasterSize(viewBox); - // The generated SVGs have no width/height; browsers need them to size the - // image before it is drawn onto the canvas. - const sizedSvg = svg.replace( - /((resolve) => - canvas.toBlob(resolve, mime), - ); - // Browsers without an encoder for the format silently fall back to PNG. - if (!blob || blob.type !== mime) { - throw new Error( - `Nettleseren din kan ikke lage ${formats[format].label}-bilder. Prøv SVG eller PNG.`, - ); - } - return blob; -}; - -/** - * Put the illustration on the clipboard as a PNG image. Office apps paste this - * as a picture, whereas SVG markup on the clipboard is pasted as text. - */ -export const copyIllustrationImage = async (svg: string, viewBox?: string) => { - if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { - throw new Error( - 'Nettleseren din kan ikke kopiere bilder. Last ned PNG i stedet.', - ); - } - // Pass the pending blob rather than awaiting it first: Safari only allows - // clipboard writes while the click is still "current". - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': rasterize(svg, viewBox, 'png') }), - ]); -}; +export const copyIllustrationImage = (svg: string, viewBox?: string) => + copySvgAsImage(svg, rasterSize(viewBox)); export const downloadIllustration = async ({ svg, @@ -128,7 +53,10 @@ export const downloadIllustration = async ({ }) => { const blob = format === 'svg' - ? new Blob([svg], { type: formats.svg.mime }) - : await rasterize(svg, viewBox, format); + ? new Blob([svg], { type: SVG_MIME }) + : await rasterizeSvg(svg, { + ...rasterSize(viewBox), + mime: formats[format].mime, + }); saveBlob(blob, `${fileName}.${format}`); }; diff --git a/apps/www/app/_components/image-generator/image-generator.module.css b/apps/www/app/_components/image-generator/image-generator.module.css new file mode 100644 index 0000000..2633302 --- /dev/null +++ b/apps/www/app/_components/image-generator/image-generator.module.css @@ -0,0 +1,139 @@ +.container { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--ds-size-8); + align-items: start; + max-width: 100%; + margin-block: var(--ds-size-8); + + @media (max-width: 900px) { + grid-template-columns: minmax(0, 1fr); + } +} + +.loading { + display: grid; + place-items: center; + min-height: 12rem; +} + +.form { + display: flex; + flex-direction: column; + gap: var(--ds-size-5); +} + +.preview { + display: flex; + flex-direction: column; + gap: var(--ds-size-4); + + @media (min-width: 901px) { + position: sticky; + top: calc(var(--header-height) + var(--ds-size-4)); + } +} + +/* Checkerboard so transparent areas of a template are visible. */ +.canvas { + border: 1px solid var(--ds-color-neutral-border-subtle); + border-radius: var(--ds-border-radius-md); + overflow: clip; + background-color: var(--ds-color-neutral-background-tinted); + background-image: + linear-gradient( + 45deg, + var(--ds-color-neutral-surface-hover) 25%, + transparent 25% + ), + linear-gradient( + -45deg, + var(--ds-color-neutral-surface-hover) 25%, + transparent 25% + ), + linear-gradient( + 45deg, + transparent 75%, + var(--ds-color-neutral-surface-hover) 75% + ), + linear-gradient( + -45deg, + transparent 75%, + var(--ds-color-neutral-surface-hover) 75% + ); + background-size: 20px 20px; + background-position: + 0 0, + 0 10px, + 10px -10px, + -10px 0; + + & svg { + display: block; + width: 100%; + height: auto; + } +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--ds-size-3); +} + +.size { + width: auto; + min-width: 12rem; +} + +.generator { + display: flex; + flex-direction: column; + gap: var(--ds-size-6); + max-width: 100%; + margin-block: var(--ds-size-8); + + & > .container { + margin-block: 0; + } +} + +.templatePicker { + max-width: 24rem; +} + +.imageField { + display: flex; + flex-direction: column; + gap: var(--ds-size-2); +} + +.dropZone { + --dsc-file-upload-padding: var(--ds-size-5) var(--ds-size-4); +} + +.imageChosen { + display: flex; + align-items: center; + gap: var(--ds-size-3); + padding: var(--ds-size-2); + border: 1px solid var(--ds-color-neutral-border-subtle); + border-radius: var(--ds-border-radius-md); +} + +.imageThumbnail { + flex-shrink: 0; + width: var(--ds-size-14); + height: var(--ds-size-14); + border-radius: var(--ds-border-radius-sm); + object-fit: cover; +} + +.imageFileName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/www/app/_components/image-generator/image-generator.tsx b/apps/www/app/_components/image-generator/image-generator.tsx new file mode 100644 index 0000000..7d1f7d0 --- /dev/null +++ b/apps/www/app/_components/image-generator/image-generator.tsx @@ -0,0 +1,385 @@ +import { + Alert, + Button, + Field, + // Experimental in Designsystemet 1.x; drop the alias when it graduates. + EXPERIMENTAL_FileUpload as FileUpload, + Label, + Paragraph, + Select, + Spinner, + Textfield, + ValidationMessage, +} from '@digdir/designsystemet-react'; +import { + ArrowUndoIcon, + DownloadIcon, + FilesIcon, + ImageIcon, + TrashIcon, +} from '@navikt/aksel-icons'; +import cl from 'clsx/lite'; +import { + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + type ImageTemplateId, + imageTemplateIds, + imageTemplates, +} from '~/_assets/templates'; +import { readImageFile } from '~/_utils/image-file'; +import { copySvgAsImage, rasterizeSvg, saveBlob } from '~/_utils/svg-export'; +import { embedInterFont } from '~/_utils/svg-fonts'; +import classes from './image-generator.module.css'; +import { type ParsedTemplate, parseTemplate } from './svg-template'; + +const scales = [1, 2] as const; +type Scale = (typeof scales)[number]; + +interface ImageGeneratorProps { + /** Template selected initially. Defaults to the first one. */ + template?: ImageTemplateId; + className?: string; +} + +/** + * Pick an SVG template, edit its texts, upload images into its `[image]` + * shapes and export the result as a PNG. The preview is the live SVG. + */ +export const ImageGenerator = ({ + template, + className, +}: ImageGeneratorProps) => { + const [templateId, setTemplateId] = useState( + template ?? imageTemplateIds[0], + ); + // Designsystemet form fields set ids and sizing on the client, so render the + // editor after mount to avoid hydration mismatches (as the signature + // generator does). + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const source = imageTemplates[templateId]; + + // Template markup is loaded on demand (exports can be megabytes). + const [svg, setSvg] = useState(null); + const [loadError, setLoadError] = useState(false); + useEffect(() => { + let cancelled = false; + setSvg(null); + setLoadError(false); + source?.load().then( + (markup) => !cancelled && setSvg(markup), + () => !cancelled && setLoadError(true), + ); + return () => { + cancelled = true; + }; + }, [source]); + + if (!source) { + return ( + + Fant ikke malen «{templateId}». + + ); + } + if (!mounted) { + return
; + } + + return ( +
+ {imageTemplateIds.length > 1 && ( + + + + + )} + {loadError ? ( + + Malen «{source.name}» kunne ikke lastes. Prøv igjen senere. + + ) : svg === null ? ( +
+ +
+ ) : ( + // Keyed so all state starts fresh when the template changes. + + )} +
+ ); +}; + +const Editor = ({ name, svg: templateSvg }: { name: string; svg: string }) => { + const parsed = useMemo(() => parseTemplate(templateSvg), [templateSvg]); + const [values, setValues] = useState(() => + parsed.fields.map((field) => field.defaultValue), + ); + const [scale, setScale] = useState(1); + const [exportError, setExportError] = useState(null); + + // Widths of the template's own texts, measured from the first render (which + // shows the defaults) so `[center]`/`[right]` fields anchor where the + // designer placed them. + const previewRef = useRef(null); + const [widths, setWidths] = useState<(number | undefined)[]>([]); + useLayoutEffect(() => { + const measured: (number | undefined)[] = []; + for (const text of previewRef.current?.querySelectorAll('text') ?? []) { + const index = Number(text.dataset.field); + measured[index] = text.querySelector('tspan')?.getComputedTextLength(); + } + setWidths(measured); + }, [parsed]); + + const svg = useMemo( + () => parsed.render(values, widths), + [parsed, values, widths], + ); + const size = { width: parsed.width * scale, height: parsed.height * scale }; + + const setValue = (index: number, value: string) => + setValues((current) => + current.map((existing, i) => (i === index ? value : existing)), + ); + + const run = async (action: 'download' | 'copy') => { + setExportError(null); + try { + const exportSvg = await embedInterFont(svg); + if (action === 'download') { + const blob = await rasterizeSvg(exportSvg, { + ...size, + mime: 'image/png', + }); + saveBlob(blob, `${name}.png`); + } else { + await copySvgAsImage(exportSvg, size); + } + } catch (error) { + setExportError( + error instanceof Error + ? error.message + : 'Bildet kunne ikke lages. Prøv igjen.', + ); + } + }; + + return ( +
+
{ + event.preventDefault(); + run('download'); + }} + > + {parsed.fields.map((field, index) => + field.kind === 'image' ? ( + setValue(index, value)} + /> + ) : ( + setValue(index, event.target.value)} + /> + ), + )} +
+ +
+ + +
+
+
+ + + + + + +
+
+ {exportError && ( + + {exportError} + + )} +
+
+
+ ); +}; + +const sizeLabel = (parsed: ParsedTemplate, factor: Scale) => + `${Math.round(parsed.width * factor)} × ${Math.round(parsed.height * factor)} px`; + +const sanitizeImageSrc = (candidate: string): string | null => { + const trimmed = candidate.trim(); + if ( + /^data:image\/(?:png|jpe?g|webp|gif);base64,[a-zA-Z0-9+/=]+$/i.test(trimmed) + ) { + return trimmed; + } + return null; +}; + +/** + * Upload for an `[image]` shape: Designsystemet drop zone plus the chosen + * file. A file that can't be used is reported right under the field, with + * `aria-invalid` on the input, per Designsystemet's error pattern. + */ +const ImageField = ({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (dataUrl: string) => void; +}) => { + const inputRef = useRef(null); + const inputId = useId(); + const [fileName, setFileName] = useState(null); + const [error, setError] = useState(null); + + const pick = async (file: File | undefined) => { + if (!file) return; + try { + onChange(await readImageFile(file)); + setFileName(file.name); + setError(null); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Bildet kunne ikke leses. Prøv en annen fil.', + ); + if (inputRef.current) inputRef.current.value = ''; + } + }; + + const clear = () => { + onChange(''); + setFileName(null); + setError(null); + if (inputRef.current) inputRef.current.value = ''; + }; + + return ( + + + + +
+ {error && {error}} +
+ {(() => { + const safeImageSrc = sanitizeImageSrc(value); + return ( + safeImageSrc && ( +
+ + + {fileName} + + +
+ ) + ); + })()} +
+ ); +}; + +export default ImageGenerator; diff --git a/apps/www/app/_components/image-generator/svg-template.ts b/apps/www/app/_components/image-generator/svg-template.ts new file mode 100644 index 0000000..45b5b0e --- /dev/null +++ b/apps/www/app/_components/image-generator/svg-template.ts @@ -0,0 +1,363 @@ +/** + * Turns an Illustrator-exported SVG into an editable template and renders it + * back with new content. Pure string processing, so it runs on both server + * and client. + * + * - Every `` becomes a text field. Illustrator exports kerned text as + * one `` per glyph run with absolute `x` positions; those are + * collapsed into one line per distinct `y`, and on render each line is a + * single `` anchored at the original line start so the browser does + * the kerning. All other attributes on `` are kept as exported. + * Illustrator does not export text alignment, so a layer name ending in + * `[center]` or `[right]` marks the anchor; unmarked text is left-aligned. + * Centring needs the original text's rendered width, which only a browser + * can measure – pass it to `render` via `widths` (see `data-field`). + * - Every shape whose layer name ends in `[image]` becomes an image field. + * An uploaded image fills the shape through an SVG pattern sized to the + * shape's bounding box, so any shape works without measuring it. + * + * Field labels come from the layer name (`data-name`, falling back to `id`). + * Illustrator writes the name on the object itself, or on a wrapping `` + * when the object is alone in its own layer – in that case the group's name + * (and any marker in it) is inherited by the single text/shape inside. + * Fields are listed top-to-bottom, then left-to-right, regardless of drawing + * order. + */ +export type TextAlign = 'start' | 'middle' | 'end'; + +export type TemplateField = + | { + kind: 'text'; + label: string; + /** Line breaks separate lines, as in the template. */ + defaultValue: string; + /** Number of lines in the template, used to size the editor. */ + rows: number; + /** From a `[center]`/`[right]` marker in the layer name. */ + align: TextAlign; + } + | { + kind: 'image'; + label: string; + /** Image fields start empty and take a data URL. */ + defaultValue: ''; + }; + +export type ParsedTemplate = { + width: number; + height: number; + fields: TemplateField[]; + /** + * Values in the same order as `fields`. `widths` (same order) are the + * rendered widths of each text field's first line in the *template*, used + * to anchor centred and right-aligned text; without them text is + * left-aligned. Every `` in the output carries `data-field=""` + * so they can be measured. + */ + render: (values: string[], widths?: (number | undefined)[]) => string; +}; + +type Node = + | { + kind: 'text'; + attributes: string; + x: number; + y: number; + lineHeight: number; + } + | { kind: 'image'; tag: string; attributes: string; original: string }; + +const IMAGE_MARKER = /\s*\[image\]\s*$/i; +const ALIGN_MARKER = /\s*\[(center|right|left)\]\s*$/i; +const alignments: Record = { + center: 'middle', + right: 'end', + left: 'start', +}; +const PLACEHOLDER = //g; + +const attribute = (attributes: string, name: string) => + attributes.match(new RegExp(`\\s${name}="([^"]*)"`))?.[1]; + +const stripAttributes = (attributes: string, names: string[]) => + attributes + .replace(new RegExp(`\\s(?:${names.join('|')})="[^"]*"`, 'g'), '') + .trimEnd(); + +const decodeEntities = (text: string) => + text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code: string) => + String.fromCodePoint(Number(code)), + ) + .replace(/&/g, '&'); + +const escapeXml = (text: string) => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +/** Position used to order fields for editing (rows, then columns). */ +const positionOf = (attributes: string) => { + const translate = attribute(attributes, 'transform')?.match( + /translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/, + ); + return { + x: Number( + translate?.[1] ?? + attribute(attributes, 'cx') ?? + attribute(attributes, 'x') ?? + 0, + ), + y: Number( + translate?.[2] ?? + attribute(attributes, 'cy') ?? + attribute(attributes, 'y') ?? + 0, + ), + }; +}; + +const SHAPES = 'circle|rect|ellipse|path|polygon|polyline'; + +/** + * For every `` and shape, the `data-name` of the nearest ancestor `` + * that has one – but only when that group contains exactly one element of + * that kind, so a layer name applies to the object it wraps and nothing else. + * Keyed by the element's offset in `svg`. + */ +const inheritedNames = (svg: string) => { + type Group = { name?: string; texts: number; shapes: number }; + const groups: Group[] = []; + const owners = new Map(); + const result = new Map(); + + const tag = new RegExp(`<(/?)(g|text|${SHAPES})\\b([^>]*?)(/?)>`, 'g'); + let match = tag.exec(svg); + while (match) { + const [, closing, name, attributes, selfClosing] = match; + if (name === 'g') { + if (closing) groups.pop(); + else if (!selfClosing) { + groups.push({ + name: attribute(attributes, 'data-name'), + texts: 0, + shapes: 0, + }); + } + } else if (!closing) { + const named = [...groups].reverse().find((group) => group.name); + if (named) { + if (name === 'text') named.texts += 1; + else named.shapes += 1; + owners.set(match.index, named); + } + if (name === 'text') { + // Skip the body so tspans etc. are not tokenised. + tag.lastIndex = svg.indexOf('', match.index) + 1; + } + } + match = tag.exec(svg); + } + + for (const [offset, group] of owners) { + const isText = svg.startsWith(' { + const counts = new Map(); + for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1); + const seen = new Map(); + return labels.map((label) => { + if ((counts.get(label) ?? 0) < 2) return label; + const n = (seen.get(label) ?? 0) + 1; + seen.set(label, n); + return `${label} ${n}`; + }); +}; + +/** Collapse a `` body into lines: one per distinct tspan `y`. */ +const linesOf = (body: string) => { + const lines = new Map(); + const tspan = /]*)>([\s\S]*?)<\/tspan>/g; + let match = tspan.exec(body); + if (!match) { + return [{ x: 0, y: 0, text: decodeEntities(body.trim()) }]; + } + while (match) { + const y = Number(attribute(match[1], 'y') ?? 0); + const x = Number(attribute(match[1], 'x') ?? 0); + const text = decodeEntities(match[2]); + const line = lines.get(y); + if (line) line.text += text; + else lines.set(y, { x, text }); + match = tspan.exec(body); + } + return [...lines.entries()] + .sort(([a], [b]) => a - b) + .map(([y, line]) => ({ y, ...line })); +}; + +export const parseTemplate = (svg: string): ParsedTemplate => { + const root = svg.match(/]*>/)?.[0] ?? ''; + const [, , width = 0, height = 0] = (attribute(root, 'viewBox') ?? '') + .split(/[\s,]+/) + .map(Number); + + // Collected in document order; `fields` is later sorted by position. + const entries: { + field: TemplateField; + node: Node; + position: { x: number; y: number }; + }[] = []; + const placeholder = () => ``; + + const body = svg.replace(/<\?xml[^>]*\?>\s*/, ''); + const inherited = inheritedNames(body); + + const skeleton = body.replace( + new RegExp( + `]*)>([\\s\\S]*?)|<(${SHAPES})\\b([^>]*?)/>`, + 'g', + ), + ( + original: string, + textAttributes: string | undefined, + textBody: string | undefined, + shapeTag: string | undefined, + shapeAttributes: string | undefined, + offset: number, + ) => { + if (textAttributes !== undefined && textBody !== undefined) { + const lines = linesOf(textBody); + const fontSize = Number(attribute(textAttributes, 'font-size') ?? 16); + const layerName = + attribute(textAttributes, 'data-name') ?? + inherited.get(offset) ?? + attribute(textAttributes, 'id') ?? + `Tekst ${entries.length + 1}`; + const marker = layerName.match(ALIGN_MARKER); + entries.push({ + field: { + kind: 'text', + label: layerName.replace(ALIGN_MARKER, '').trim() || layerName, + defaultValue: lines.map((line) => line.text).join('\n'), + rows: lines.length, + align: marker ? alignments[marker[1].toLowerCase()] : 'start', + }, + node: { + kind: 'text', + attributes: stripAttributes(textAttributes, [ + 'id', + 'data-name', + 'xml:space', + ]), + x: lines[0].x, + y: lines[0].y, + lineHeight: + lines.length > 1 ? lines[1].y - lines[0].y : fontSize * 1.2, + }, + position: positionOf(textAttributes), + }); + return placeholder(); + } + + if (shapeTag === undefined || shapeAttributes === undefined) { + return original; + } + const layerName = + attribute(shapeAttributes, 'data-name') ?? inherited.get(offset) ?? ''; + if (!IMAGE_MARKER.test(layerName)) return original; + entries.push({ + field: { + kind: 'image', + label: layerName.replace(IMAGE_MARKER, '').trim() || 'Bilde', + defaultValue: '', + }, + node: { + kind: 'image', + tag: shapeTag, + attributes: stripAttributes(shapeAttributes, [ + 'id', + 'data-name', + 'fill', + ]), + original, + }, + position: positionOf(shapeAttributes), + }); + return placeholder(); + }, + ); + + // Editing order: rows top to bottom, then left to right (a few px of + // baseline jitter counts as the same row). `order[fieldIndex]` is the + // node index. + const ROW_TOLERANCE = 8; + const order = entries + .map((entry, index) => ({ index, ...entry.position })) + .sort((a, b) => + Math.abs(a.y - b.y) > ROW_TOLERANCE ? a.y - b.y : a.x - b.x, + ) + .map(({ index }) => index); + const labels = uniqueLabels(order.map((index) => entries[index].field.label)); + const fields = order.map((index, fieldIndex) => ({ + ...entries[index].field, + label: labels[fieldIndex], + })); + + const render = (values: string[], widths?: (number | undefined)[]) => { + const fieldIndexOf = new Map( + order.map((nodeIndex, fieldIndex) => [nodeIndex, fieldIndex]), + ); + return skeleton.replace(PLACEHOLDER, (_, index: string) => { + const nodeIndex = Number(index); + const fieldIndex = fieldIndexOf.get(nodeIndex) ?? 0; + const { field, node } = entries[nodeIndex]; + const value = values[fieldIndex] ?? ''; + + if (node.kind === 'image') { + if (!value) return node.original; + const id = `template-image-${nodeIndex}`; + return ( + `` + + `` + + `` + + `<${node.tag}${node.attributes} fill="url(#${id})"/>` + ); + } + + // Anchor centred/right-aligned text where the template's text was + // centred/ended; falls back to the left edge until widths are known. + const width = widths?.[fieldIndex]; + const align = field.kind === 'text' && width ? field.align : 'start'; + const x = + align === 'middle' + ? node.x + (width ?? 0) / 2 + : align === 'end' + ? node.x + (width ?? 0) + : node.x; + const anchor = align === 'start' ? '' : ` text-anchor="${align}"`; + const tspans = value + .split('\n') + .map( + (line, i) => + `${escapeXml(line)}`, + ) + .join(''); + return `${tspans}`; + }); + }; + + return { width, height, fields, render }; +}; diff --git a/apps/www/app/_components/mdx-components/mdx-components.tsx b/apps/www/app/_components/mdx-components/mdx-components.tsx index bdb36df..bc9f087 100644 --- a/apps/www/app/_components/mdx-components/mdx-components.tsx +++ b/apps/www/app/_components/mdx-components/mdx-components.tsx @@ -33,6 +33,7 @@ import { DownloadLink } from '../download-link/download-link'; import { EmailSignatureGenerator } from '../email-signature-generator/email-signatur-generator'; import ExpandableImage from '../expandable-image/expandable-image'; import { IllustrationLibrary } from '../illustration-library/illustration-library'; +import { ImageGenerator } from '../image-generator/image-generator'; import classes from './mdx-components.module.css'; /** Use a client-side router link for internal paths, a plain anchor otherwise. */ @@ -66,6 +67,7 @@ const defaultComponents = { DownloadLink, EmailSignatureGenerator, IllustrationLibrary, + ImageGenerator, h1: (props: HeadingProps) => ( ), diff --git a/apps/www/app/_utils/image-file.ts b/apps/www/app/_utils/image-file.ts new file mode 100644 index 0000000..4fe74e1 --- /dev/null +++ b/apps/www/app/_utils/image-file.ts @@ -0,0 +1,29 @@ +/** + * Read an image file chosen by the user into a data URL, downscaled so the + * longest side is at most `maxSide` pixels. Keeps PNG for transparency, + * otherwise re-encodes as JPEG to keep the SVG and PNG exports small. + */ +export const readImageFile = async (file: File, maxSide = 2000) => { + if (!file.type.startsWith('image/')) { + throw new Error('Filen må være et bilde i PNG, JPEG eller WebP.'); + } + + const bitmap = await createImageBitmap(file).catch(() => { + throw new Error('Bildet kunne ikke leses. Prøv en annen fil.'); + }); + const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height)); + const width = Math.round(bitmap.width * scale); + const height = Math.round(bitmap.height * scale); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Kunne ikke behandle bildet.'); + context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + + return file.type === 'image/png' + ? canvas.toDataURL('image/png') + : canvas.toDataURL('image/jpeg', 0.9); +}; diff --git a/apps/www/app/_utils/svg-export.ts b/apps/www/app/_utils/svg-export.ts new file mode 100644 index 0000000..6236a19 --- /dev/null +++ b/apps/www/app/_utils/svg-export.ts @@ -0,0 +1,102 @@ +/** + * Browser-side helpers for turning SVG markup into files and images: + * rasterise to PNG/WebP via a canvas, trigger a download, copy as an image. + */ +export const SVG_MIME = 'image/svg+xml'; + +export type RasterMime = 'image/png' | 'image/webp'; + +/** Trigger a download of `blob` as `fileName`. */ +export const saveBlob = (blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + // Give the browser a moment to start the download before revoking. + setTimeout(() => URL.revokeObjectURL(url), 10_000); +}; + +/** Width and height of an SVG `viewBox` attribute value. */ +export const viewBoxSize = (viewBox?: string) => { + const [, , width, height] = (viewBox ?? '') + .trim() + .split(/[\s,]+/) + .map(Number); + return width && height ? { width, height } : undefined; +}; + +const loadSvgImage = (svg: string) => + new Promise((resolve, reject) => { + const url = URL.createObjectURL(new Blob([svg], { type: SVG_MIME })); + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(url); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Kunne ikke lese SVG-en.')); + }; + image.src = url; + }); + +/** + * Render SVG markup to a raster of exactly `width` × `height` pixels with a + * transparent background. Fonts and images referenced from outside the SVG + * are not available while rasterising – embed them first. + */ +export const rasterizeSvg = async ( + svg: string, + { width, height, mime }: { width: number; height: number; mime: RasterMime }, +): Promise => { + // Browsers need explicit dimensions to size the image before drawing it. + const sizedSvg = svg.replace( + /]*>/, + (root) => + `${root.replace(/\s(?:width|height)="[^"]*"/g, '').replace(/>$/, '')} width="${width}" height="${height}">`, + ); + const image = await loadSvgImage(sizedSvg); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Kunne ikke opprette bildet.'); + context.drawImage(image, 0, 0, width, height); + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, mime), + ); + // Browsers without an encoder for the format silently fall back to PNG. + if (!blob || blob.type !== mime) { + throw new Error( + `Nettleseren din kan ikke lage ${mime === 'image/webp' ? 'WebP' : 'PNG'}-bilder.`, + ); + } + return blob; +}; + +/** + * Put a PNG rendering of the SVG on the clipboard. Office apps paste this as + * a picture, whereas SVG markup on the clipboard is pasted as text. + */ +export const copySvgAsImage = async ( + svg: string, + size: { width: number; height: number }, +) => { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { + throw new Error( + 'Nettleseren din kan ikke kopiere bilder. Last ned PNG i stedet.', + ); + } + // Pass the pending blob rather than awaiting it first: Safari only allows + // clipboard writes while the click is still "current". + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': rasterizeSvg(svg, { ...size, mime: 'image/png' }), + }), + ]); +}; diff --git a/apps/www/app/_utils/svg-fonts.ts b/apps/www/app/_utils/svg-fonts.ts new file mode 100644 index 0000000..719fa70 --- /dev/null +++ b/apps/www/app/_utils/svg-fonts.ts @@ -0,0 +1,61 @@ +/** + * Inlines the Inter font into SVG markup before it is rasterised. + * + * The site already loads Inter, and the `` elements keep the font + * settings Illustrator exported – but a canvas renders an SVG in an isolated + * image document that cannot see the page's fonts, so without this the PNG + * would fall back to a system font. The files are the same ones the page + * loads, so they normally come straight from the browser cache. + */ +const FONT_BASE = 'https://altinncdn.no/fonts/inter/v4.1/'; + +const faces = { + normal: 'InterVariable.woff2', + italic: 'InterVariable-Italic.woff2', +} as const; + +type FontStyle = keyof typeof faces; + +const fontFaceCache = new Map>(); + +const toBase64 = (buffer: ArrayBuffer) => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +}; + +/** `@font-face` rule for one style with the font file inlined, fetched once. */ +const getFontFace = (style: FontStyle) => { + let promise = fontFaceCache.get(style); + if (!promise) { + promise = fetch(FONT_BASE + faces[style]) + .then((response) => { + if (!response.ok) throw new Error(response.statusText); + return response.arrayBuffer(); + }) + .then( + (buffer) => + `@font-face{font-family:Inter;font-style:${style};font-weight:100 900;src:url(data:font/woff2;base64,${toBase64(buffer)}) format('woff2')}`, + ) + .catch((error) => { + fontFaceCache.delete(style); // allow a retry + throw new Error(`Kunne ikke laste skriften Inter (${error.message}).`); + }); + fontFaceCache.set(style, promise); + } + return promise; +}; + +/** Add an inline ``, + ); +}; diff --git a/apps/www/app/content/digdir/profilering/bildegenerator.mdx b/apps/www/app/content/digdir/profilering/bildegenerator.mdx new file mode 100644 index 0000000..226197d --- /dev/null +++ b/apps/www/app/content/digdir/profilering/bildegenerator.mdx @@ -0,0 +1,29 @@ +--- +title: Bildegenerator +sidebar_title: Bildegenerator +description: Lag bilder til sosiale medier og presentasjoner fra ferdige maler. Skriv inn tekstene dine, se resultatet direkte og last ned som PNG. +category: Profilering +order: 20 +published: true +--- + +Velg en mal, og skriv inn tekstene i feltene til venstre. Noen maler har også et felt der du kan laste opp et bilde. Forhåndsvisningen oppdaterer seg mens du skriver. Når du er fornøyd, laster du ned bildet som PNG eller kopierer det rett inn i PowerPoint eller Word. + + + +## Lage mal +Det er eit par regla for å få korrekt redigering over. + +### Tekst +Tekst må være faktisk tekst, av typen `Inter`. +Dersom teksten ikkje skal være venstrejustert må du markere dette i brackets bakom navnet på layeren. +Navnet på layeren blir for eksempel: `Navn [center]` eller `Navn [right]`. + +### Bilde +For at brukaren skal kunne last opp bilde må layeren ha `[image]` i navnet. +Navnet på layeren blir for eksempel: `Profilbilde [image]`. + +### Namn og markørar +Namnet kan stå på sjølve objektet, eller på laget/gruppa som berre inneheld dette eine objektet. +Namnet blir brukt som ledetekst i redigeringa, så gje alle tekstar eit namn. +Ein SVG med innebygde bilete (til dømes gradientar) blir fort fleire megabyte – eksporter berre den eine artboarden, og unngå bilete der du kan. \ No newline at end of file diff --git a/packages/varde/illustrations/digdir/person-med-sloyfe-og-stjerner/person-med-sloyfe-og-stjerner.svg b/packages/varde/illustrations/digdir/person-med-sloyfe-og-stjerner/person-med-sloyfe-og-stjerner.svg index eb9b8bc..146521f 100644 --- a/packages/varde/illustrations/digdir/person-med-sloyfe-og-stjerner/person-med-sloyfe-og-stjerner.svg +++ b/packages/varde/illustrations/digdir/person-med-sloyfe-og-stjerner/person-med-sloyfe-og-stjerner.svg @@ -1,33 +1,10 @@ - - - - - - - + + + + + + + + - + \ No newline at end of file diff --git a/packages/varde/scripts/build-illustrations.ts b/packages/varde/scripts/build-illustrations.ts index 67a95c7..faab7ec 100644 --- a/packages/varde/scripts/build-illustrations.ts +++ b/packages/varde/scripts/build-illustrations.ts @@ -615,6 +615,12 @@ const buildProfile = async (profile: string) => { ' }', '}', '', + '@media (prefers-color-scheme: light) {', + ` [data-color-scheme='auto'] {`, + declarations('light').replace(/^/gm, ' '), + ' }', + '}', + '', ].join('\n'); fs.writeFileSync(path.join(distDir, `${profile}.css`), css);