Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/web/src/lib/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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ダウンロード機能は現在無効です',
Expand Down
148 changes: 138 additions & 10 deletions packages/web/src/lib/qr.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -189,28 +190,155 @@ describe('qrCaptionLines', () => {
});
});

describe('qrPngFileName', () => {
it('is "<name>_QR.png"', () => {
expect(qrPngFileName('造形大ポスター')).toBe('造形大ポスター_QR.png');
describe('qrFileName', () => {
it('is "<name>_QR.<format>"', () => {
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<string, string> {
const open = svg.slice(0, svg.indexOf('>'));
const found: Record<string, string> = {};
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('<text');
});

it('grows by one line height per caption line, matching the PNG layout', async () => {
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('<text');
});

// A transparent quiet zone prints as nothing and does not scan.
it('paints the background white', async () => {
expect(await qrSvgString(URL_)).toContain(
`<rect width="${WIDTH}" height="${WIDTH}" fill="#ffffff"/>`,
);
});

it('nests the code at the padding offset, keeping its own viewBox', async () => {
const svg = await qrSvgString(URL_);
const nested = svg.slice(svg.indexOf('<svg', 1));
expect(attrs(nested)).toMatchObject({
x: '32',
y: '32',
width: '640',
height: '640',
'shape-rendering': 'crispEdges',
});
expect(attrs(nested).viewBox).toMatch(/^0 0 \d+ \d+$/);
});

it('emphasises only the line that asked for it', async () => {
const svg = await qrSvgString(URL_, [
{ text: 'Poster A', emphasis: true },
{ text: 'Poster · 1F' },
]);
const texts = [...svg.matchAll(/<text[^>]*>([^<]*)<\/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(/<text x="352" y="(\d+)"/g)].map((match) =>
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 <test>' }]);
expect(svg).toContain('>A &amp; B &lt;test&gt;</text>');
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');
}
});
});
Loading
Loading