1cd43b988080

fix Codex PDF restore — decode canvas pixels directly via AztecCodeReader instead of BrowserAztecCodeReader (which silently fails on canvas elements); shrink detached window to 320x550 (v3.6.75)

time
2026-05-21 14:23 UTC · 5 months ago
machine
koh2
parent
e32372210412
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/codex/pdf-import.ts+78 −30src/popup/views/CodexRestoreView.tsx+6 −8
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.74",5+  "version": "3.6.75",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.74",4+  "version": "3.6.75",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.74",3+  "version": "3.6.75",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/codex/pdf-import.ts+78 −30
@@ -16,7 +16,14 @@1616  * cloud storage, this path is faster and more reliable than camera scanning.1717  */1818 19-import { BrowserAztecCodeReader, NotFoundException } from "@zxing/library";19+import {20+  AztecCodeReader,21+  BinaryBitmap,22+  GlobalHistogramBinarizer,23+  HybridBinarizer,24+  NotFoundException,25+  RGBLuminanceSource,26+} from "@zxing/library";2027 2128 import {2229   CODES_PER_COL,@@ -112,7 +119,6 @@112119     };113120   }114121 115-  const reader = new BrowserAztecCodeReader();116122   const pageCount = pdf.numPages;117123 118124   for (let pageNum = 1; pageNum <= pageCount; pageNum++) {@@ -138,7 +144,7 @@138144 139145       await page.render({ canvas, canvasContext: ctx, viewport }).promise;140146 141-      const pageEnvelopes = await scanCanvasGrid(canvas, reader, seenIndices);147+      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);142148       for (const env of pageEnvelopes) envelopes.push(env);143149 144150       if (totalExpected === null && pageEnvelopes.length > 0) {@@ -164,21 +170,31 @@164170  * try to decode it. Cells that contain no code — the trailing empty cells on165171  * the last page — just throw NotFoundException and are skipped.166172  *167- * Cropping must follow the *exact* layout drawn by `pdf.ts`, not a naive168- * `canvas / 6` slice: the page has 28pt margins, a 60pt header band, and169- * fixed 80pt code cells with small gaps. The earlier even-grid slicer cut170- * every Aztec on its right edge, producing "no Codex codes found" even on a171- * pristine digital PDF (the binarizer can't find a finder pattern in a172- * clipped symbol). Geometry constants are shared with the generator via173- * `pdf-layout.ts` so the two can't drift again.173+ * Two non-obvious things this function gets right that earlier revisions did174+ * not:174175  *176+ *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,177+ *      header band, code size, column/row gaps) instead of a naive178+ *      `canvas / 6` slice. The naive slicer clipped every Aztec on its179+ *      right edge — ZXing's binarizer can't find a finder pattern in a180+ *      chopped symbol. Geometry constants are shared with the generator181+ *      via `pdf-layout.ts` so the two can't drift again.182+ *183+ *   2. We decode the cell pixels directly through `AztecCodeReader` +184+ *      `RGBLuminanceSource` rather than handing the canvas to185+ *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only186+ *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when187+ *      you pass it a canvas, its internal `createCaptureCanvas` falls188+ *      through to no branch and creates a zero-sized capture canvas, so189+ *      every decode returns `NotFoundException`. The DOM-free path here is190+ *      the same one the generator uses for round-trip verification.191+ *175192  * Around each code we also include a small quiet-zone margin (≈12% of the176- * code size). The PDF prints codes against the cream background with no177- * required quiet zone; ZXing's locator is happier with one.193+ * code size). The PDF prints codes against a cream background with no194+ * required quiet zone; ZXing's locator is happier with a white one.178195  */179196 async function scanCanvasGrid(180197   canvas: HTMLCanvasElement,181-  reader: BrowserAztecCodeReader,182198   seenIndices: Set<number>,183199 ): Promise<ChunkEnvelope[]> {184200   const found: ChunkEnvelope[] = [];@@ -200,6 +216,12 @@200216   const quietX = Math.round(codeWpx * 0.12);201217   const quietY = Math.round(codeHpx * 0.12);202218 219+  // One scratch canvas reused across all cells — cheaper than allocating220+  // 36 canvases per page when a Codex spans dozens of pages.221+  const cellCanvas = document.createElement("canvas");222+  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });223+  if (!cellCtx) return found;224+203225   for (let row = 0; row < CODES_PER_COL; row++) {204226     for (let col = 0; col < CODES_PER_ROW; col++) {205227       const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;@@ -217,31 +239,17 @@217239       );218240       if (sw <= 0 || sh <= 0) continue;219241 220-      const cellCanvas = document.createElement("canvas");221242       cellCanvas.width = sw;222243       cellCanvas.height = sh;223-      const cellCtx = cellCanvas.getContext("2d");224-      if (!cellCtx) continue;225-226244       // Paint the quiet-zone background white before drawing — the source227245       // PDF's cream is light but not pure white, and a clean white border228-      // gives ZXing the cleanest finder-pattern contrast.246+      // gives the binarizer the cleanest finder-pattern contrast.229247       cellCtx.fillStyle = "white";230248       cellCtx.fillRect(0, 0, sw, sh);231249       cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);232250 233-      let text: string;234-      try {235-        // @zxing/library's BrowserCodeReader.decode accepts any media element236-        // at runtime — it branches on instanceof — but the TS signature only237-        // lists HTMLImageElement. Cast through unknown to bypass the false238-        // positive.239-        const result = reader.decode(cellCanvas as unknown as HTMLImageElement);240-        text = result.getText();241-      } catch (err) {242-        if (err instanceof NotFoundException) continue;243-        continue;244-      }251+      const text = decodeAztecFromCellPixels(cellCtx, sw, sh);252+      if (!text) continue;245253 246254       const envelope = parseChunkEnvelope(text);247255       if (!envelope) continue;@@ -254,6 +262,46 @@254262   }255263 256264   return found;265+}266+267+/**268+ * Read the cell canvas's pixels into an ARGB Int32Array, wrap it in a269+ * `RGBLuminanceSource`, and try to decode it as an Aztec code. Returns the270+ * decoded text on success, or `null` if no code was found.271+ *272+ * We try `HybridBinarizer` first (good for rendered images with subtle273+ * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`274+ * (better when the image is dominated by clean black/white modules). A fresh275+ * `AztecCodeReader` is allocated per attempt — the reader caches internal276+ * state that must be reset between bitmaps.277+ */278+function decodeAztecFromCellPixels(279+  cellCtx: CanvasRenderingContext2D,280+  sw: number,281+  sh: number,282+): string | null {283+  const imageData = cellCtx.getImageData(0, 0, sw, sh);284+  const raw = imageData.data;285+  const argb = new Int32Array(sw * sh);286+  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {287+    argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;288+  }289+290+  const source = new RGBLuminanceSource(argb, sw, sh);291+292+  try {293+    const bitmap = new BinaryBitmap(new HybridBinarizer(source));294+    return new AztecCodeReader().decode(bitmap).getText();295+  } catch (err) {296+    if (!(err instanceof NotFoundException)) return null;297+  }298+299+  try {300+    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));301+    return new AztecCodeReader().decode(bitmap).getText();302+  } catch {303+    return null;304+  }257305 }258306 259307 /**

view file

src/popup/views/CodexRestoreView.tsx+6 −8
@@ -189,17 +189,15 @@189189       // without hard-coding "src/popup/index.html".190190       const popupHtml =191191         chrome.runtime.getManifest().action?.default_popup ?? "src/popup/index.html";192-      // Match the popup's intrinsic 320×520 body; add small allowances for193-      // the OS title bar so the popup isn't clipped and so the rendered194-      // window doesn't stretch a giant black background around a tiny popup195-      // (the earlier 460×720 left ~150px of dead space on the right and196-      // ~250px on the bottom). Chrome counts these as outer dimensions, so197-      // the interior is ~popup-sized after subtracting native chrome.192+      // Match the popup's intrinsic 320×520 body. Chrome's popup-type193+      // window has near-zero side chrome on macOS, so width:320 lines up194+      // with the popup body and avoids the black band on the right that195+      // any wider window produces. Height gets ~30pt for the title bar.198196       await chrome.windows.create({199197         url: chrome.runtime.getURL(`${popupHtml}?codex=pdf`),200198         type: "popup",201-        width: 340,202-        height: 560,199+        width: 320,200+        height: 550,203201       });204202       window.close();205203     } catch (err) {

view file