Skip to content

fix(download): PNG 背景の透明状態が原因の barcode reader decode 失敗を白塗りで解消 - #458

Merged
fumtas1k merged 2 commits into
developfrom
fix/gs1-databar-vertical-quiet-zone
May 20, 2026
Merged

fix(download): PNG 背景の透明状態が原因の barcode reader decode 失敗を白塗りで解消#458
fumtas1k merged 2 commits into
developfrom
fix/gs1-databar-vertical-quiet-zone

Conversation

@fumtas1k

Copy link
Copy Markdown
Owner

Summary

  • ツール生成 PNG が image-based barcode reader (Dynamsoft Barcode Reader online 等) で Found 0 barcodes になる事象を修正
  • 真因は PNG quiet zone が透明 (α=0) だったこと。reader が transparent pixel を黒判定し全面ノイズ → decode 不能
  • 修正は svgContentToPngBlob / downloadPngFromSvgElement の Canvas2D に fillStyle = 'white' + fillRectctx.scale() 前に追加するのみ。SVG / preview / VRT への影響なし
  • 経緯詳細は docs/decisions.md [082] に記録 (誤仮説 [垂直 quiet zone 不足] → 実機 pixel 計測で真因確定の過程)

影響範囲

  • GS1 DataBar 生成ツール (svgContentToPngBlob 経由) の PNG ダウンロード
  • JAN コードツール (downloadPngFromSvgElement 経由) の PNG ダウンロード
  • どちらも barcode reader 互換性が向上 (画素値の quiet zone を透明 → 白 のみの修正)

陽性対照テスト

  • unit (6 件) in src/utils/__tests__/download.test.ts
    • svgContentToPngBlob 経路 3 件 / downloadPngFromSvgElement 経路 3 件
    • fillStyle === 'white', fillRect(0, 0, canvas.width, canvas.height), call 順序 (fillRect → scale → drawImage) を観測
  • E2E (1 件) in tests/e2e/gs1-databar.spec.ts
    • composite シンボル生成 → 実 Canvas で PNG を再生成 → 4 隅 quiet zone pixel が α=255 / RGB=(255,255,255) であることを getImageData で assert
  • いずれも fix を revert すると α=0 transparent に戻り 全件 fail する設計 (test-gates 鉄則 1 準拠)

仮説差し替え経緯 (debugging note)

  1. 初期仮説: 垂直 quiet zone 不足 → paddingheight: 10 を試行
  2. 実機検証: paddingheight 適用後も Dynamsoft で Found 0 → 仮説外れ
  3. screenshot は依然読める事実に着目し pixel 計測 → quiet zone 4 隅すべて α=0, RGB=(0,0,0) 発見
  4. 真因確定 → Canvas に白塗り → user 環境で Found 1 barcode (confidence: 100) 確認
  5. YAGNI に従い paddingheight 関連変更は revert (詳細 decisions.md [082])

Test plan

  • ローカル unit test (1293 → 1293 + 6 新規)
  • 型チェック (0 errors)
  • Playwright 実機検証 (composite 生成 PNG の 4 隅 pixel = white)
  • user 環境で Dynamsoft online reader の decode 成功確認 (Found 1 barcode, confidence: 100, GS1_COMPOSITE)
  • CI: unit + type check
  • CI: E2E (既存の develop branch に CSP regression があり gs1-databar test が現状 fail 状態。新規追加 pixel-level 陽性対照は実機検証で代用済)
  • reviewer による code review

🤖 Generated with Claude Code

`svgContentToPngBlob` / `downloadPngFromSvgElement` は Canvas2D の default 透明背景の
まま `drawImage(svg)` していたため、bwip-js / JsBarcode の SVG (背景 rect なし) を経由
した PNG は quiet zone / バー間 pixel が α=0 transparent になっていた。image-based
barcode reader (Dynamsoft 等) は transparent pixel を「黒」と解釈する実装があり、
quiet zone が黒判定 → 全面ノイズ → decode 不能になっていた (実例: dev server 生成 PNG
→ Dynamsoft 0 件、同 PNG を screenshot 経由 → confidence=100 で decode 成功)。

Canvas に `fillStyle = 'white'` + `fillRect(0, 0, canvas.width, canvas.height)` を
`ctx.scale()` 前に呼んで device px 単位で全面白塗りすることで、生成 PNG の quiet zone
を実 RGB 白として記録する。GS1 DataBar と JAN コードの両 PNG 出力が恩恵を受ける。

陽性対照: unit 6 件 (fillStyle='white', fillRect 引数, call 順序) + E2E 1 件
(生成 PNG 4 隅 pixel の α=255 / RGB=255 を `getImageData` で実観測)。fix を revert
すると α=0 (transparent) に戻り全件 fail する設計。

経緯詳細: docs/decisions.md [082]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fumtas1k fumtas1k self-assigned this May 20, 2026
@github-actions

github-actions Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

🖼️ Visual Regression Test 結果

  • Status: ✅ 全 40 件 pass
  • Workflow run: 26170773944
  • Artifact (diff 画像 / playwright-report): 上記 workflow run の Artifacts セクションから download

diff が 意図的な visual 変更の場合: Update Visual Regression Baseline workflow を本 PR ブランチで workflow_dispatch trigger して baseline を更新。
diff が 意図しない regression の場合: 該当変更を fix。
本 check は required ではないため fail のままでも merge は可能(reviewer 判断)。

@fumtas1k

Copy link
Copy Markdown
Owner Author

レビュー: #458 PNG 背景の透明状態が原因の barcode reader decode 失敗を白塗りで解消

多角的に確認しました。LGTM, 軽微な改善提案あり (blocker なし) です。


1. 概要

svgContentToPngBlob / downloadPngFromSvgElement の Canvas2D に fillStyle = 'white' + fillRect(0, 0, width, height)scale 前 に追加する 16 行の局所修正。透過 PNG を image-based reader (Dynamsoft) が「黒」と誤判定して decode 失敗していた事象の修正で、SVG / preview / VRT への影響なし。decisions.md [082] に誤仮説 → 真因確定の経緯が残されており再現性が高いです。

2. ロジック正しさ ✅

  • 塗り順序 fillRect → scale → drawImage が正しい: ctx.scale() 後に fillRect(0, 0, canvas.width, canvas.height) を呼ぶと、座標が CSS px 扱いになり右下 1/4 しか塗られない既知の罠を回避できています。コメントにも明記済み。
  • 両経路に対称適用: svgContentToPngBlob (GS1 DataBar / ZIP 一括) と downloadPngFromSvgElement (JAN コード) の両方に修正済み。ツール内の PNG 出口は grep -rn "svgContentToPngBlob\|downloadPngFromSvgElement\|toBlob.*image/png" src/ で 2 箇所のみで、漏れなし。
  • URL.revokeObjectURL の finally 配置: downloadPngFromSvgElement 側で既に PR fix: P1 issue 対応 (#392 PNG ダウンロード失敗の UI 通知 / #386 QrCode a11y) #434 由来の try/finally が確保されており、本 PR で追加した path も同じ scope 内なので memory leak リスクは増えていません。

3. セキュリティ ✅

  • 新たなユーザ入力受口なし。Canvas2D fillStyle = 'white' / fillRect は CSP style-src 対象外の純 JS API なので、production CSP (strict) 下でも違反は出ません (E2E が withProductionCsp で確認済)。
  • SVG 文字列は引き続き Blob → Image.src 経由でロードしており、innerHTML 注入経路は増えていない。
  • dangerouslySetInnerHTML / setProperty 等の memory 上の落とし穴 (CSP3 setProperty 制約等) も該当なし。

4. テスト陽性対照 ✅

  • unit 6 件 + E2E 1 件いずれも fix revert で必ず fail する設計:
    • unit: fillStyle === 'white' / fillRect(0,0,200,100) / fillRect → scale → drawImage 順序を call log で観測
    • E2E: composite 生成 PNG の 4 隅 quiet zone を getImageData で α=255 / RGB=(255,255,255) assert (expect.soft で全失敗箇所を一括表示)
  • test-gates 鉄則 1 (陽性対照併設) に準拠。

5. フロントエンドデザイン観点 ✅ (軽微な懸念 1 件)

  • VRT baseline 不変: preview は SVG 描画経路で PNG 化を通らないので問題なし。
  • UX 改善: 画像エディタに貼付時のチェッカー柄が消える副次効果あり。
  • トレードオフ (将来課題): 「透過 PNG で colored bg に重ねたい」需要は失われます。barcode tool としては仕様上 black-on-white 必須なので妥当ですが、QrCode.tsx が将来 PNG ダウンロードを追加する場合 (現状は SVG のみ) は ロゴ overlay / 色違い background 用途で透過オプションを切り替えたくなる可能性があるので、その時に検討で OK。

6. 改善提案 (任意, 本 PR 内でも別 PR でも)

# 重要度 内容
A minor unit テストの canvas mock setup が 4+ 箇所で重複。makeCanvasStub({ callLog, capturedFillStyle }) ヘルパーに集約すれば 100+ 行削減可能。本 PR スコープでは無理に統合せず別 issue で OK
B minor expect(fillRectCalls[0].args).toEqual([0, 0, 200, 100])200, 100100 * RETINA_SCALE の magic number。テスト内 const RETINA_SCALE = 2; を置いて [0, 0, 100 * RETINA_SCALE, 50 * RETINA_SCALE] の方が link が明示的
C medium E2E (gs1-databar.spec.ts:240- 付近) が svgContentToPngBlob の本体ではなく page.evaluate() 内で conversion ロジックを 再実装 している。仕様 (期待される pixel 値) は守られるが、本物の svgContentToPngBlob を bypass しても pass する false negative リスクあり。真の E2E にするなら download button を click し download event の blob を getImageData で検証する形 (page.on('download') 経由) が望ましい。ただし実装コストが高いので本 PR ではスコープ外で OK
D minor src/utils/download.ts:56-65 の block コメント (12 行) は decisions.md [082] と内容が重複気味。WHY が非自明なので削るほどではないが、 「Canvas2D default は transparent。一部 reader が transparent を黒と解釈して decode 失敗するため scale 前に白塗り。詳細: decisions.md [082]」程度に圧縮しても十分。downloadPngFromSvgElement 側 (115-118 行) の短い版くらいが balance としては丁度良いです
E follow-up PR body の test plan で「CI: E2E (既存の develop branch に CSP regression があり gs1-databar test が現状 fail 状態)」と書かれていますが、この CSP regression に対する tracking issue は起票済みですか? 未起票なら CLAUDE.md 6.4 (先送り → issue 化必須) に従い起票推奨です

7. その他確認したこと

  • Gs1Databar.tsx / JanCode.tsx の caller 側に変更不要 (関数 signature 不変)
  • QrCode.tsx は SVG ダウンロードのみで PNG 経路を持たないため影響なし
  • ZIP 一括ダウンロード (Gs1Databar.tsx:438 svgContentToPngBlob(svg)) も同経路を通るので恩恵を受ける
  • decisions.md [082] の代替案検討 (alpha: false / SVG 側 <rect fill="white"/> 注入) が網羅的で、選択の正当性が明確

結論: 修正は局所的・対称的・陽性対照あり・実機検証済みで安全に merge 可能。A〜D は nice-to-have、E のみ運用ルール上 follow-up 推奨です。

🤖 Generated with Claude Code

… 実 download 経路 / D: コメント圧縮)

#458 のレビュー指摘 4 件 (A〜D) を本 PR 内で吸収する:

- **A** (mock 重複集約): `download.test.ts` の canvas / Image / URL / anchor の stubGlobal
  構築を `setupBrowserMocks({ output: 'toBlob' | 'toDataURL' })` ヘルパーに集約。観測対象
  (callLog / capturedFillStyle / capturedCtx / imgInstance / anchors) を `MockHandles`
  経由で取得。536 → ~390 行に圧縮、各 it() の stub セットアップが 1 行に。
- **B** (magic number): `[0, 0, 200, 100]` 等の生数値を `[0, 0, 100 * RETINA_SCALE, 50 * RETINA_SCALE]`
  に置換。test ファイル上部に `const RETINA_SCALE = 2` を `download.ts` private 定数の
  mirror として宣言。将来の RETINA_SCALE 変更時の link が明示的に。
- **C** (実 download 経路に置換): E2E `生成 PNG の quiet zone が透明ではなく白` を
  `page.evaluate` 内 `svgContentToPngBlob` 再実装 → `page.waitForEvent('download')` で
  実 PNG 取得 → Node fs で読み込み → base64 化して browser に渡し `<img>` で decode する
  経路に置換。本実装 `svgContentToPngBlob` を bypass する false negative リスクを除去。
- **D** (コメント圧縮): `download.ts:56-65` の 12 行コメントを「Canvas2D default は
  transparent → 詳細 decisions.md [082]」の 3 行に圧縮。decisions.md と重複していた
  WHY 説明を一元化。`downloadPngFromSvgElement` 側 (115-118) は元々短いため不変。

陽性対照は全件保持。fix を revert すると `fillStyle` undefined / `fillRect` 不在 /
quiet zone α=0 で全件 fail する設計は維持される (test-gates 鉄則 1)。

Closes #459 (review A)
Closes #460 (review C)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fumtas1k

Copy link
Copy Markdown
Owner Author

レビューありがとうございます。指摘 A〜D を本 PR 内で対応しました (bc53042)。

対応内容

A (mock 集約) → #459 を close

download.test.ts の重複していた canvas / Image / URL / anchor の stubGlobal 構築を setupBrowserMocks({ output: 'toBlob' | 'toDataURL' }) ヘルパーに集約。

  • 観測対象 (callLog / capturedFillStyle / capturedCtx / imgInstance / anchors) を MockHandles 経由で取得
  • tainted canvas 等の SecurityError 経路は throwsOnInvoke: new Error(...) で表現
  • 結果 536 → ~390 行に圧縮、各 it() の stub セットアップは 1 行 (m = setupBrowserMocks({...})) に

B (magic number)

[0, 0, 200, 100] 等の生数値を [0, 0, 100 * RETINA_SCALE, 50 * RETINA_SCALE] に置換。test ファイル上部に const RETINA_SCALE = 2download.ts private 定数の mirror として宣言。

C (実 download 経路) → #460 を close

E2E 生成 PNG の quiet zone が透明ではなく白 を:

  • 旧: page.evaluate 内で svgContentToPngBlob のロジックを 再実装 して pixel sample (本実装を bypass する false negative リスクあり)
  • 新: page.waitForEvent('download') で実 PNG download 経由 → Node fs.readFile で blob 取得 → base64 化して page.evaluate に渡し <img> で decode → getImageData で 4 隅 sampling

これで svgContentToPngBlob の本実装 (fillStyle = 'white' + fillRect) が確実に通り、fix を revert すると α=0 で fail する保証が強化された。新規 dep 不要 (Playwright 標準 + Node 標準のみ)。

実機テスト: 単独実行で 1.0s pass を確認。

D (コメント圧縮)

src/utils/download.ts:56-65 の 12 行コメントを「Canvas2D default は transparent → 詳細 decisions.md [082]」の 3 行に圧縮。decisions.md と重複していた WHY 説明を一元化。downloadPngFromSvgElement 側 (115-118) は元々短いため不変。

E (CSP regression) → #461 で tracking 継続

develop branch の preview build で多数の inline style が CSP style-src 違反を起こしており E2E gate が事実上機能していない件は別バグ (PR #458 と無関係の事前存在問題) のため #461 を open のまま残します。本 PR の真因 fix (PNG 背景白塗り) は実機検証 + unit + E2E 単独実行で代用済み。

テスト結果

  • unit: 1293/1293 passed
  • type check: 0 errors
  • format: prettier clean
  • E2E (新規 test 単独実行): pass (1.0s)

🤖 Generated with Claude Code

@fumtas1k

Copy link
Copy Markdown
Owner Author

追加レビュー: 指摘対応 (bc53042) 確認

A〜E 全件確認しました。全て期待以上に丁寧な対応です。LGTM・merge 可です。


対応確認

# 内容 対応 評価
A mock 重複集約 `setupBrowserMocks({ output: 'toBlob' 'toDataURL', throwsOnInvoke? }) ヘルパーに集約。MockedCtx/AnchorStub/ImgInstance/MockHandlesを interface 化、観測 API はgetCapturedFillStyle()/getImgInstance()/getCreatedAnchors()` の getter で closure 安定化
B magic number test ファイル上部に const RETINA_SCALE = 2download.ts private const の mirror として宣言し、[0, 0, 100 * RETINA_SCALE, 50 * RETINA_SCALE] に置換 ✅ 完璧。コメントで mirror であることも明示されていて読み手が prod 定数との link を辿りやすい
C 実 download 経路への置換 page.waitForEvent('download')download.path() → Node readFile → base64 → browser に転送 → <img> decode → getImageData で 4 隅 sampling、という 教科書通りの Playwright PNG 検証 pattern に置換。コメントで「旧版は再実装 false negative リスク → 実 production code 経由に置換」と意図が明示済 ✅ 期待以上。これで svgContentToPngBlob を bypass した regression を確実に検知できる (issue #460 Closed)
D コメント圧縮 download.ts:56-65 の 12 行 → 3 行 (「Canvas2D default は transparent → 詳細 decisions.md [082]」)。重複していた WHY 説明が SoT (decisions.md) に一元化 ✅ 完璧。両関数のコメント密度も揃った
E CSP regression tracking issue #461 fix(csp): preview build で多数の inline style が CSP style-src 違反を起こし E2E gate が機能していない (bug, P1, security, test ラベル付き) で起票済 ✅ 完璧。CLAUDE.md 6.4 (先送り → issue 化) に準拠

追加で確認したこと

  • CI 全 green: e2e (3m11s) / test (1m0s) / visual-regression (1m26s) すべて pass
  • CSP 整合: src/utils/csp.ts:30img-src 'self' data: blob: となっており、新 E2E の img.src = 'data:image/png;base64,...' は production CSP 下で動作可能 (E2E pass で実証)
  • button locator: getByRole('button', { name: 'PNGダウンロード' })DownloadButtonGroup.tsx:13label="PNGダウンロード" と完全一致
  • 陽性対照の revert 検知能力: 新 E2E は本物の svgContentToPngBlob を経由するため、download.tsfillStyle/fillRect 行を削除すると α=0 に戻り pixel assertion で 4 隅 × 4 channel = 16 件 fail する設計が維持されている
  • テスト統合の副作用なし: 既存 7 件 (downloadBlob / svgContentToPngBlob 既存 / downloadPngFromSvgElement 既存) も setupBrowserMocks 経由に書き換えられているが、観測項目 (anchor click / imageSmoothingEnabled / Promise reject 経路) は全て保持されている

nit (任意, 本 PR では不要)


結論: 指摘 4 件 (A〜D) は本 PR で吸収、残 1 件 (E) は適切に issue 化済み。レビュー対応として申し分ない品質で、approve・merge 可です。

🤖 Generated with Claude Code

@fumtas1k
fumtas1k merged commit 977f6b6 into develop May 20, 2026
3 checks passed
@fumtas1k
fumtas1k deleted the fix/gs1-databar-vertical-quiet-zone branch May 20, 2026 16:04
fumtas1k added a commit that referenced this pull request May 20, 2026
* feat(gs1-databar): 合成シンボル上部に AI テキスト ((17)... 等) 表示を復活

PR #450 (commit c563cf5) は「ディセンダーが composite 上端 1X quiet zone に侵入し
Dynamsoft Reader decode 不能」「textRowH を 3X gap に広げる alternative も decode
不能で撤回」を根拠に `injectCompositeText` を撤去していた。だが PR #458 (commit
977f6b6) で真因は **Canvas2D の透明背景** (`svgContentToPngBlob` が `fillStyle =
'white'` 未設定で α=0) と判明、PR #450 の検証はすべて透明背景時だったため descender
仮説は red herring の可能性が高いと user が指摘。

実機検証: PR #458 の白背景 fix を merge した状態で `injectCompositeText` を旧 geometry
(textRowH=24、text baseline y=21、barcode translate y=24) でそのまま復活 → user 環境
の Dynamsoft online reader で decode 成功確認。descender 仮説否定。

- `escapeHtml` / `injectCompositeText` を `src/utils/gs1-databar.ts` に復元 (旧
  geometry そのまま、コメントブロックで復活経緯を明示)
- `src/components/tools/Gs1Databar.tsx` の wiring 復元 (compositeText 非空時のみ
  `injectCompositeText(sizedSvg, compositeText)` を `addSvgDimensions` 後に挿入)
- `.gs1-svg-container` クラスを `src/styles/global.css` に復元 (`<text>` の
  `fill="currentColor"` 継承用、preview wrapper に `barcode-preview` と併用)

陽性対照 (旧コードに当てて必ず fail する設計):
- unit: `escapeHtml` 6 件 (XSS escape) + `injectCompositeText` 8 件 (geometry / text
  配置 y=21 / viewBox 高さ +24 / barcode translate / 横拡張 centering / XSS escape /
  inner content 保持) — 計 14 件
- E2E: `composite シンボルに AI テキスト ((17)... (10)...) が SVG 上部に注入される`
  (preview SVG の <text> 要素 + 内容 + y=21 / text-anchor / fontSize / fill 観測) +
  `non-composite (AI フィールド未入力) シンボルには AI テキスト注入されない`
  (常時 injection regression 検知) — 計 2 件

経緯詳細: docs/decisions.md [083]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(gs1-databar): #462 review A 対応 (injectCompositeText の width/height 置換 regex を <svg> ルートに anchor)

旧 `/width="\d+"/` / `/height="\d+"/` regex は SVG 内の最初の match を置換するため、
将来 bwip-js / addSvgDimensions の変更で svg root に width/height 属性が無くなった
場合に子要素 (`<rect width="N">` 等) を wrong match して破壊する silent regression
リスクがあった。`/(<svg\s[^>]*?)width="\d+"/` に anchor することで svg 開始タグ内の
属性のみを確実に置換する。

現在の coding path (addSvgDimensions が必ず width/height を注入) では顕在化しないが、
将来のリファクタ時の防御として導入。

陽性対照テスト: `<svg>` ルートに width 属性無し + 子要素 `<rect width="10">` ありの
fixture で injectCompositeText を呼び、子要素の `<rect width="10" height="20">` が
そのまま残ることを assert。anchor を外すと最初の match が `<rect width="10">` に
なり `<rect width="124">` (newW=124) に誤置換されて assert fail する設計。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
fumtas1k added a commit that referenced this pull request May 20, 2026
release: develop → main (#458 PNG 背景透明 fix + #462 合成シンボル AI テキスト復活)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant