diff --git a/packages/web/src/lib/i18n.tsx b/packages/web/src/lib/i18n.tsx index 4534fe4..cb349d9 100644 --- a/packages/web/src/lib/i18n.tsx +++ b/packages/web/src/lib/i18n.tsx @@ -182,12 +182,13 @@ const en: Dictionary = { 'Could not copy. Copying needs an https or localhost address — select the link and copy it by hand.', 'qrCodes.generateFailed': 'Failed to generate the QR code', 'qrCodes.downloadButton': 'Download PNG', + 'qrCodes.downloadSvgButton': 'Download SVG', 'qrCodes.scanCount': '{count} scans', 'qrCodes.scanCountTitle': 'Scans recorded for this code', 'qrCodes.captionLegend': 'Text printed under the code', - 'qrCodes.captionHint': 'Printed under the code in the downloaded PNG.', + 'qrCodes.captionHint': 'Printed under the code in the downloaded image.', 'qrCodes.captionHintNone': - 'The downloaded PNG will contain the code only, with no text.', + 'The downloaded image will contain the code only, with no text.', 'csvExport.downloadFailed': 'Failed to download the CSV', 'csvExport.disabled': 'CSV export is not enabled yet', @@ -362,12 +363,13 @@ const ja: Dictionary = { 'コピーできませんでした。コピーには https か localhost のアドレスが必要です。リンクを選択して手動でコピーしてください。', 'qrCodes.generateFailed': 'QRコードの生成に失敗しました', 'qrCodes.downloadButton': 'PNGをダウンロード', + 'qrCodes.downloadSvgButton': 'SVGをダウンロード', 'qrCodes.scanCount': '{count} 回', 'qrCodes.scanCountTitle': 'このQRコードのアクセス数', 'qrCodes.captionLegend': 'QRコードの下に入れる文字', - 'qrCodes.captionHint': 'ダウンロードするPNGのQRコードの下に印字されます。', + 'qrCodes.captionHint': 'ダウンロードする画像のQRコードの下に印字されます。', 'qrCodes.captionHintNone': - 'ダウンロードするPNGにはQRコードだけが入り、文字は入りません。', + 'ダウンロードする画像にはQRコードだけが入り、文字は入りません。', 'csvExport.downloadFailed': 'CSVのダウンロードに失敗しました', 'csvExport.disabled': 'CSVダウンロード機能は現在無効です', diff --git a/packages/web/src/lib/qr.test.ts b/packages/web/src/lib/qr.test.ts index a2bb6ef..a4f3bb4 100644 --- a/packages/web/src/lib/qr.test.ts +++ b/packages/web/src/lib/qr.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { QR_CAPTION_DEFAULTS, deriveFallbackKey, qrCaptionLines, - qrPngFileName, + qrFileName, + qrSvgString, qrTargetUrl, } from './qr'; @@ -189,28 +190,155 @@ describe('qrCaptionLines', () => { }); }); -describe('qrPngFileName', () => { - it('is "_QR.png"', () => { - expect(qrPngFileName('造形大ポスター')).toBe('造形大ポスター_QR.png'); +describe('qrFileName', () => { + it('is "_QR."', () => { + expect(qrFileName('造形大ポスター', 'png')).toBe('造形大ポスター_QR.png'); + expect(qrFileName('造形大ポスター', 'svg')).toBe('造形大ポスター_QR.svg'); }); // The medium and location were dropped from the filename on purpose. it('carries the name only', () => { - expect(qrPngFileName('看板A')).toBe('看板A_QR.png'); + expect(qrFileName('看板A', 'png')).toBe('看板A_QR.png'); }); it('collapses whitespace rather than leaving it in the path', () => { - expect(qrPngFileName('造形大 ポスター')).toBe('造形大_ポスター_QR.png'); + expect(qrFileName('造形大 ポスター', 'png')).toBe( + '造形大_ポスター_QR.png', + ); }); it('strips characters no filesystem accepts', () => { - expect(qrPngFileName('a/b:c*d')).toBe('abcd_QR.png'); + expect(qrFileName('a/b:c*d', 'svg')).toBe('abcd_QR.svg'); }); // name is required by the API, so this is defence rather than a real case — // but a leading underscore would look like a broken filename. it('does not leave a stray separator when the name is blank', () => { - expect(qrPngFileName('')).toBe('QR.png'); - expect(qrPngFileName('///')).toBe('QR.png'); + expect(qrFileName('', 'png')).toBe('QR.png'); + expect(qrFileName('///', 'svg')).toBe('QR.svg'); + }); +}); + +/** + * The SVG is assembled as a string rather than through the DOM, so the things that + * can break it are structural: a caption containing `&`, a geometry that stops + * matching the PNG, a QR that lands in the wrong place. All of that is checkable + * without a canvas — which is the reason fitText tolerates a null context. + */ +describe('qrSvgString', () => { + // Stubbed rather than left to jsdom: jsdom's own getContext returns null but + // reports a "Not implemented" error to its virtual console on the way, which + // lands in the test output looking like a failure. Stating it here also makes + // the no-measurement branch a deliberate condition instead of a side effect — + // captions therefore come out untruncated below, which is the documented + // behaviour when text cannot be measured. + beforeAll(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + }); + + const URL_ = 'https://t.nutfes.net/?id=q7mfe3x&p=instagram'; + // QR_SIZE 640 + PADDING 32 on each side. + const WIDTH = 704; + // One caption line adds CAPTION_LINE_HEIGHT 34 + PADDING / 2. + const CAPTION_BLOCK = 34 + 16; + + function attrs(svg: string): Record { + const open = svg.slice(0, svg.indexOf('>')); + const found: Record = {}; + for (const [, key, value] of open.matchAll(/([\w:-]+)="([^"]*)"/g)) { + found[key] = value; + } + return found; + } + + it('is a square page with no caption', async () => { + const svg = await qrSvgString(URL_); + expect(attrs(svg)).toMatchObject({ + xmlns: 'http://www.w3.org/2000/svg', + width: String(WIDTH), + height: String(WIDTH), + viewBox: `0 0 ${WIDTH} ${WIDTH}`, + }); + expect(svg).not.toContain(' { + const one = await qrSvgString(URL_, [{ text: 'A' }]); + const two = await qrSvgString(URL_, [{ text: 'A' }, { text: 'B' }]); + expect(attrs(one).height).toBe(String(WIDTH + CAPTION_BLOCK)); + expect(attrs(two).height).toBe(String(WIDTH + CAPTION_BLOCK + 34)); + // The page never gets wider than the code plus its margins. + expect(attrs(two).width).toBe(String(WIDTH)); + }); + + it('ignores blank caption lines rather than leaving a gap', async () => { + const svg = await qrSvgString(URL_, [{ text: '' }, { text: '' }]); + expect(attrs(svg).height).toBe(String(WIDTH)); + expect(svg).not.toContain(' { + expect(await qrSvgString(URL_)).toContain( + ``, + ); + }); + + it('nests the code at the padding offset, keeping its own viewBox', async () => { + const svg = await qrSvgString(URL_); + const nested = svg.slice(svg.indexOf(' { + const svg = await qrSvgString(URL_, [ + { text: 'Poster A', emphasis: true }, + { text: 'Poster · 1F' }, + ]); + const texts = [...svg.matchAll(/]*>([^<]*)<\/text>/g)]; + expect(texts.map((match) => match[1])).toEqual(['Poster A', 'Poster · 1F']); + expect(texts[0]?.[0]).toContain('font-weight="600"'); + expect(texts[1]?.[0]).not.toContain('font-weight'); + }); + + // Baselines are explicit numbers rather than dominant-baseline, which vector + // editors interpret inconsistently — so the second line has to sit exactly one + // line height below the first. + it('spaces caption baselines by one line height', async () => { + const svg = await qrSvgString(URL_, [{ text: 'A' }, { text: 'B' }]); + const ys = [...svg.matchAll(/ + Number(match[1]), + ); + expect(ys).toHaveLength(2); + expect((ys[1] as number) - (ys[0] as number)).toBe(34); + }); + + it('escapes a caption that would otherwise break the document', async () => { + const svg = await qrSvgString(URL_, [{ text: 'A & B ' }]); + expect(svg).toContain('>A & B <test>'); + const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml'); + expect(parsed.querySelector('parsererror')).toBeNull(); + }); + + it('parses as SVG for every caption count', async () => { + for (const lines of [ + [], + [{ text: '造形大ポスター', emphasis: true }], + [{ text: '造形大ポスター', emphasis: true }, { text: 'ポスター · 1F' }], + ]) { + const parsed = new DOMParser().parseFromString( + await qrSvgString(URL_, lines), + 'image/svg+xml', + ); + expect(parsed.querySelector('parsererror')).toBeNull(); + expect(parsed.documentElement.tagName).toBe('svg'); + } }); }); diff --git a/packages/web/src/lib/qr.ts b/packages/web/src/lib/qr.ts index aa46f58..b54a879 100644 --- a/packages/web/src/lib/qr.ts +++ b/packages/web/src/lib/qr.ts @@ -96,8 +96,11 @@ export function qrTargetUrl(qr: QrLinkIdentity, fallbackKey = ''): string { return fallbackKey ? `${base}&p=${encodeURIComponent(fallbackKey)}` : base; } +/** The formats a QR code can be downloaded as. */ +export type QrImageFormat = 'png' | 'svg'; + /** - * Filename for a downloaded QR code PNG: `_QR.png`. + * Filename for a downloaded QR code: `_QR.`. * * Only the name goes in. The medium and location were in here as well, which * buried the one part anyone actually scans a folder for behind two fields that @@ -106,11 +109,11 @@ export function qrTargetUrl(qr: QrLinkIdentity, fallbackKey = ''): string { * Extracted rather than inlined for the same reason as qrTargetUrl: a bulk-print * view will need exactly this, and the two must not drift. */ -export function qrPngFileName(name: string): string { +export function qrFileName(name: string, format: QrImageFormat): string { // 'QR' is passed as a part rather than appended, so the separator collapsing // in slugForFilename applies to it too — a blank name gives "QR.png", not // "_QR.png". - return `${slugForFilename(name, 'QR')}.png`; + return `${slugForFilename(name, 'QR')}.${format}`; } /** On-screen preview. */ @@ -122,10 +125,51 @@ export function qrPreviewDataUrl(text: string): Promise { }); } -const PNG_QR_SIZE = 640; -const PNG_PADDING = 32; +/** + * Geometry shared by both download formats — deliberately not prefixed per + * format. The PNG and the SVG have to come out at the same size with the same + * margins, or "the same image in another format" stops being true and swapping + * one for the other in a poster file means redoing the placement. + */ +const QR_SIZE = 640; +const PADDING = 32; const CAPTION_LINE_HEIGHT = 34; const CAPTION_FONT_SIZE = 24; +/** + * Font stack for the caption. Only the SVG needs it spelled out — a data file is + * opened somewhere other than a browser, so the chain has to name real fonts + * rather than rely on `system-ui` resolving. + */ +const CAPTION_FONT_FAMILY = + "system-ui, -apple-system, 'Segoe UI', 'Helvetica Neue', 'Hiragino Sans', 'Noto Sans JP', 'Yu Gothic', sans-serif"; +/** + * Top edge of a caption line to its baseline, ~0.8em at the sizes used here. The + * canvas path sets textBaseline='top' and has no need for it; the SVG path + * positions text on the alphabetic baseline and does — see qrSvgString. + */ +const CAPTION_BASELINE_OFFSET = Math.round(CAPTION_FONT_SIZE * 0.8); + +/** + * The single place either format's page size is decided. + * + * `qrSize` is passed in rather than assumed to equal QR_SIZE so the margin comes + * out at exactly PADDING on all four sides of whatever the renderer actually + * produced — the canvas renderer floors its own dimensions internally. + */ +function qrImageLayout(qrSize: number, lineCount: number) { + const width = qrSize + PADDING * 2; + return { + width, + height: + qrSize + + PADDING * 2 + + (lineCount ? lineCount * CAPTION_LINE_HEIGHT + PADDING / 2 : 0), + /** Top edge of the first caption line. */ + captionTop: qrSize + PADDING + PADDING / 2, + /** A caption line wider than this gets an ellipsis. */ + maxTextWidth: width - PADDING * 2, + }; +} /** One printed line beneath the QR code. */ export interface QrCaptionLine { @@ -185,12 +229,43 @@ export function qrCaptionLines( return lines; } +/** Font shorthand for a caption line, shared by the measuring and canvas paths. */ +function captionFont(emphasis: boolean | undefined): string { + return `${emphasis ? '600 ' : ''}${CAPTION_FONT_SIZE}px ${CAPTION_FONT_FAMILY}`; +} + +/** + * A context kept solely for measureText. + * + * The SVG path has no canvas of its own but still needs text widths to decide + * where to truncate, and it has to agree with the PNG path or the two formats + * truncate at different points. Null where no 2D context exists (jsdom, which + * implements none) — an untruncated caption is a far better failure than no SVG at + * all, and it is what makes qrSvgString unit-testable. + * + * Resolved once: whether a context is obtainable cannot change during a session, + * and a fresh canvas per caption line is pure waste. + */ +let cachedMeasureContext: CanvasRenderingContext2D | null | undefined; +function measureContext(): CanvasRenderingContext2D | null { + if (cachedMeasureContext === undefined) { + try { + cachedMeasureContext = document.createElement('canvas').getContext('2d'); + } catch { + cachedMeasureContext = null; + } + } + return cachedMeasureContext; +} + /** Shortens a caption line to fit the image width, with an ellipsis. */ function fitText( - ctx: CanvasRenderingContext2D, + ctx: CanvasRenderingContext2D | null, text: string, maxWidth: number, ): string { + // No way to measure means no way to know where to cut. See measureContext. + if (!ctx) return text; if (ctx.measureText(text).width <= maxWidth) return text; let low = 0; let high = text.length; @@ -220,18 +295,16 @@ export async function qrPngBlob( ): Promise { const qrCanvas = document.createElement('canvas'); await QRCodeLib.toCanvas(qrCanvas, text, { - width: PNG_QR_SIZE, + width: QR_SIZE, margin: 2, errorCorrectionLevel: 'H', }); const lines = captionLines.filter((line) => line.text); + const layout = qrImageLayout(qrCanvas.width, lines.length); const canvas = document.createElement('canvas'); - canvas.width = qrCanvas.width + PNG_PADDING * 2; - canvas.height = - qrCanvas.height + - PNG_PADDING * 2 + - (lines.length ? lines.length * CAPTION_LINE_HEIGHT + PNG_PADDING / 2 : 0); + canvas.width = layout.width; + canvas.height = layout.height; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas 2D context unavailable'); @@ -240,17 +313,20 @@ export async function qrPngBlob( // scanners need the quiet zone to actually be light. ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(qrCanvas, PNG_PADDING, PNG_PADDING); + ctx.drawImage(qrCanvas, PADDING, PADDING); if (lines.length) { ctx.fillStyle = '#000000'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - const maxWidth = canvas.width - PNG_PADDING * 2; - let y = qrCanvas.height + PNG_PADDING + PNG_PADDING / 2; + let y = layout.captionTop; for (const line of lines) { - ctx.font = `${line.emphasis ? '600 ' : ''}${CAPTION_FONT_SIZE}px system-ui, sans-serif`; - ctx.fillText(fitText(ctx, line.text, maxWidth), canvas.width / 2, y); + ctx.font = captionFont(line.emphasis); + ctx.fillText( + fitText(ctx, line.text, layout.maxTextWidth), + canvas.width / 2, + y, + ); y += CAPTION_LINE_HEIGHT; } } @@ -262,3 +338,86 @@ export async function qrPngBlob( }, 'image/png'); }); } + +/** Escapes a caption for an SVG text node. A name may legitimately contain `&`. */ +function escapeXmlText(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Renders a QR code to an SVG document, laid out identically to qrPngBlob. + * + * The point of the vector form is print: a 640px PNG placed on an A1 poster is + * enlarged past its resolution, and softened module edges cost real scan + * reliability at distance. This output stays sharp at any size and drops straight + * into Illustrator or Inkscape. + * + * The caption is emitted as live , which makes it editable in a vector + * editor but leaves its glyphs to whatever font that machine resolves from + * CAPTION_FONT_FAMILY. Only the caption is affected; the code itself is paths. + */ +export async function qrSvgString( + text: string, + captionLines: QrCaptionLine[] = [], +): Promise { + // The library hands back a complete element — xmlns, viewBox in module + // units, shape-rendering="crispEdges", and width/height equal to QR_SIZE + // verbatim. Nesting that element and giving it an x/y is valid SVG 1.1 and + // understood by browsers and vector editors alike, so this rides on the + // library's own path generation instead of re-deriving modules into s. + const qrSvg = ( + await QRCodeLib.toString(text, { + type: 'svg', + width: QR_SIZE, + margin: 2, + errorCorrectionLevel: 'H', + }) + ).trim(); + + const lines = captionLines.filter((line) => line.text); + const layout = qrImageLayout(QR_SIZE, lines.length); + const ctx = measureContext(); + + const caption = lines.map((line, index) => { + if (ctx) ctx.font = captionFont(line.emphasis); + // Positioned on the default alphabetic baseline rather than with + // dominant-baseline="text-before-edge", which browsers honour but vector + // editors interpret inconsistently. An explicit number renders the same + // everywhere. + const y = + layout.captionTop + CAPTION_BASELINE_OFFSET + index * CAPTION_LINE_HEIGHT; + const weight = line.emphasis ? ' font-weight="600"' : ''; + return ( + `${escapeXmlText(fitText(ctx, line.text, layout.maxTextWidth))}` + ); + }); + + return [ + ``, + // Same reason as the canvas fill above: the quiet zone has to actually be + // light, and a transparent SVG prints as nothing. + ``, + qrSvg.replace('', + ].join('\n'); +} + +/** The downloadable file for either format, ready for downloadBlob. */ +export async function qrImageBlob( + format: QrImageFormat, + text: string, + captionLines: QrCaptionLine[] = [], +): Promise { + if (format === 'png') return qrPngBlob(text, captionLines); + // charset is spelled out because captions are routinely Japanese and a + // consumer that guesses latin-1 renders them as mojibake. + return new Blob([await qrSvgString(text, captionLines)], { + type: 'image/svg+xml;charset=utf-8', + }); +} diff --git a/packages/web/src/pages/QRCodesPage.tsx b/packages/web/src/pages/QRCodesPage.tsx index 6e20d18..ab1de5c 100644 --- a/packages/web/src/pages/QRCodesPage.tsx +++ b/packages/web/src/pages/QRCodesPage.tsx @@ -35,11 +35,12 @@ import { useTranslation } from '../lib/i18n'; import { QR_CAPTION_DEFAULTS, type QrCaptionOptions, + type QrImageFormat, type QrLinkIdentity, deriveFallbackKey, qrCaptionLines, - qrPngBlob, - qrPngFileName, + qrFileName, + qrImageBlob, qrPreviewDataUrl, qrTargetUrl, } from '../lib/qr'; @@ -205,7 +206,8 @@ function QRDialog({ [qr.id, qr.shortCode, fallbackKey], ); const { dataUrl, failed } = useQRDataUrl(url); - const [isDownloading, setIsDownloading] = useState(false); + // Which format is being generated, so only the button that was pressed spins. + const [downloading, setDownloading] = useState(null); const [caption, setCaption] = useCaptionOptions(); const captionLines = useMemo( @@ -213,16 +215,16 @@ function QRDialog({ [qr, caption], ); - const handleDownload = async () => { - setIsDownloading(true); + const handleDownload = async (format: QrImageFormat) => { + setDownloading(format); try { // Saved via a blob. Previously this was // ``: the file was // unidentifiable in a downloads folder, and on iOS Safari a data: URL // tends to navigate in-tab rather than save — destroying this dialog in // the process. - const blob = await qrPngBlob(url, captionLines); - downloadBlob(blob, qrPngFileName(qr.name)); + const blob = await qrImageBlob(format, url, captionLines); + downloadBlob(blob, qrFileName(qr.name, format)); } catch (error) { toast.error( error instanceof Error @@ -230,7 +232,7 @@ function QRDialog({ : t('common.genericError'), ); } finally { - setIsDownloading(false); + setDownloading(null); } }; @@ -241,19 +243,26 @@ function QRDialog({ title={t('qrCodes.dialogTitle')} className="sm:max-w-sm" footer={ - + // PNG stays the primary action: it is what most people want, and the + // vector form only matters to whoever is assembling the printed piece. +
+ + +
} >
@@ -334,6 +343,45 @@ function QRDialog({ ); } +/** + * One of the two format buttons in the dialog footer. + * + * Extracted only because the pair is otherwise identical markup twice over, and + * the spinner-vs-icon swap is the sort of detail that ends up implemented one way + * on one button and another way on the other. + */ +function DownloadButton({ + format, + label, + busy, + disabled, + onDownload, + className, +}: { + format: QrImageFormat; + label: string; + busy: boolean; + disabled: boolean; + onDownload: (format: QrImageFormat) => Promise; + className: string; +}) { + return ( + + ); +} + /** * Copies `value` and says so. *