@anaralabs/lector 3.13.4 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -138,16 +138,20 @@ declare interface AnnotationTooltipProps {
138
138
  * right after, so pdf.js readbacks (`ctx.fillStyle`, `copyCtxState`,
139
139
  * save/restore) always observe original-document colors and nothing is ever
140
140
  * mapped twice. Gradients are recolored per stop at creation. `drawImage`
141
- * and `putImageData` are deliberately untouched — photos keep their pixels —
142
- * with one exception: a drawImage that composes a luminosity soft mask
141
+ * and `putImageData` keep image pixels untouched — photos stay photos — with
142
+ * two exceptions: a drawImage that composes a luminosity soft mask
143
143
  * (destination-in through a pdf.js `*_luminosity_map_*` filter) draws a
144
144
  * luma-corrected copy of the mask so the recoloring doesn't invert the
145
- * mask's alpha.
145
+ * mask's alpha, and a drawImage that papers the page with near-white pixels
146
+ * (a scanned page) is inverted toward the palette poles right after painting
147
+ * (policy in ./scan-invert.ts).
146
148
  *
147
149
  * Wrapping installs own properties on the context instance (the prototype is
148
- * never modified). Returns a cleanup that restores the pristine context.
150
+ * never modified). Returns a cleanup that restores the pristine context; pass
151
+ * finalizeRender=true only when the render completed naturally, which lets a
152
+ * papered-but-inkless page finalize as a genuinely blank scanned page.
149
153
  */
150
- export declare function applyContextRecolor(ctx: CanvasRenderingContext2D, map: RenderColorMap): () => void;
154
+ export declare function applyContextRecolor(ctx: CanvasRenderingContext2D, map: RenderColorMap, options?: RecolorContextOptions): (finalizeRender?: boolean) => void;
151
155
 
152
156
  /**
153
157
  * Calculates the highlight rectangles for a given text match.
@@ -183,6 +187,8 @@ export declare const clamp: (value: number, minimum: number, maximum: number) =>
183
187
 
184
188
  export declare function clampScaleForPage(targetScale: number, pageWidth: number, pageHeight: number, maxPixels?: number): number;
185
189
 
190
+ export declare function classifyScanPaper(source: CanvasImageSource, crop?: SourceCrop): ScanPaperClass | null;
191
+
186
192
  declare type CollapsibleSelection = {
187
193
  highlights: HighlightRect[];
188
194
  underlines?: HighlightRect[];
@@ -331,6 +337,8 @@ export declare interface InitialPDFState {
331
337
 
332
338
  export declare const IS_MOBILE_DEVICE: boolean;
333
339
 
340
+ export declare function isScanPaperSource(source: CanvasImageSource, crop?: SourceCrop): boolean;
341
+
334
342
  export declare class LinkService {
335
343
  _pdfDocumentProxy?: PDFDocumentProxy;
336
344
  externalLinkEnabled: boolean;
@@ -463,6 +471,18 @@ export declare type PDFVirtualizer = Virtualizer<any, any>;
463
471
 
464
472
  export declare const PreviousPage: () => void;
465
473
 
474
+ export declare interface RecolorContextOptions {
475
+ /**
476
+ * Full-page area in the context's device space (viewport.width × height).
477
+ * When provided, image draws that paper the page (≥ SCAN_COVERAGE_MIN of
478
+ * this area) with near-white pixels — scanned pages — are inverted toward
479
+ * the palette poles. Omit on pdf.js scratch canvases, whose device space
480
+ * is not the page; a page-covering scan painted through one is still
481
+ * caught when the scratch canvas is composed back onto a page context.
482
+ */
483
+ pageArea?: number;
484
+ }
485
+
466
486
  /**
467
487
  * Removes any recolor wrapper installed on the context. For long-lived
468
488
  * contexts (the visible canvas in the no-buffer fallback, thumbnails) that
@@ -488,6 +508,60 @@ export declare const Root: ForwardRefExoticComponent<Omit<Omit<HTMLProps<HTMLDiv
488
508
  loader?: ReactNode;
489
509
  }, "ref"> & RefAttributes<unknown>>;
490
510
 
511
+ /** Painted area relative to the page above which a draw "papers the page". */
512
+ export declare const SCAN_COVERAGE_MIN = 0.7;
513
+
514
+ /**
515
+ * Minimum count of dark "ink" pixels in the downsample. A pure-white raster
516
+ * is an MRC background layer (its ink lives in a separate dark layer that
517
+ * must stay un-inverted to remain readable) or a blank page — skip both.
518
+ * A single genuine dark sample suffices so sparse pages (a signature, a few
519
+ * lines) still qualify; downsample averaging already erases isolated noise.
520
+ */
521
+ export declare const SCAN_INK_MIN_PIXELS = 1;
522
+
523
+ /**
524
+ * Minimum opaque fraction: scans are fully opaque; a cut-out PNG would let
525
+ * the inversion fill bleed into its transparent holes.
526
+ */
527
+ export declare const SCAN_OPAQUE_MIN_FRACTION = 0.99;
528
+
529
+ /**
530
+ * Maximum saturated fraction. Stamps and seals on real scans measure a few
531
+ * percent saturated; a rasterized slide with a colored chart measures far
532
+ * more — inverting it would render the chart as a negative.
533
+ */
534
+ export declare const SCAN_SATURATED_MAX_FRACTION = 0.15;
535
+
536
+ /**
537
+ * Minimum density (summed tile area / union bounding box area) for pending
538
+ * tiles to read as one contiguous scan: strips partitioning a page are dense,
539
+ * scattered white screenshots on a text page are not.
540
+ */
541
+ export declare const SCAN_TILE_DENSITY_MIN = 0.85;
542
+
543
+ /**
544
+ * Minimum painted fraction for a draw to count as a scan tile: some raster
545
+ * PDFs encode a page as strips or grids (8×8 grids put single tiles under
546
+ * 2%) whose areas only paper the page in sum. The floor only excludes
547
+ * degenerate sliver spam — the density, saturation, page-level ink and
548
+ * paint-serial guards do the real false-positive filtering.
549
+ */
550
+ export declare const SCAN_TILE_MIN_FRACTION = 0.005;
551
+
552
+ /** Fraction of near-white opaque pixels above which a source is scan paper. */
553
+ export declare const SCAN_WHITE_MIN_FRACTION = 0.6;
554
+
555
+ export declare interface ScanPaperClass {
556
+ /**
557
+ * Whether the paper carries dark ink pixels. A pure-white raster is an
558
+ * MRC background layer (its ink lives in a separate dark layer that must
559
+ * stay readable) or a blank margin tile — pages only invert when ink is
560
+ * present somewhere, but blank tiles still count toward tiled coverage.
561
+ */
562
+ inked: boolean;
563
+ }
564
+
491
565
  export declare const Search: ({ children, loading }: SearchProps) => ReactNode;
492
566
 
493
567
  declare interface SearchOptions {
@@ -524,6 +598,18 @@ declare interface SelectionTooltipProps {
524
598
 
525
599
  export declare type Source = string | URL | TypedArray | ArrayBuffer | DocumentInitParameters;
526
600
 
601
+ export declare interface SourceCrop {
602
+ sx: number;
603
+ sy: number;
604
+ sw: number;
605
+ sh: number;
606
+ }
607
+
608
+ export declare function sourceSize(source: CanvasImageSource): {
609
+ width: number;
610
+ height: number;
611
+ } | null;
612
+
527
613
  export declare const subscribeToViewportInvalidation: (viewport: HTMLDivElement, callback: ViewportCallback) => () => void;
528
614
 
529
615
  declare type TextContent = {
package/dist/index.js CHANGED
@@ -1417,6 +1417,96 @@ function computeBaseScale(dpr, zoom, pageWidth, pageHeight) {
1417
1417
  );
1418
1418
  }
1419
1419
 
1420
+ // src/lib/scan-invert.ts
1421
+ var SCAN_COVERAGE_MIN = 0.7;
1422
+ var SCAN_TILE_MIN_FRACTION = 5e-3;
1423
+ var SCAN_TILE_DENSITY_MIN = 0.85;
1424
+ var SCAN_WHITE_MIN_FRACTION = 0.6;
1425
+ var SCAN_INK_MIN_PIXELS = 1;
1426
+ var SCAN_OPAQUE_MIN_FRACTION = 0.99;
1427
+ var SCAN_SATURATED_MAX_FRACTION = 0.15;
1428
+ var SAMPLE_TARGET_PX = 64;
1429
+ var bitmapVerdicts = /* @__PURE__ */ new WeakMap();
1430
+ var scratchCanvas = null;
1431
+ function getScratchContext(width, height) {
1432
+ if (typeof document === "undefined") return null;
1433
+ scratchCanvas ??= document.createElement("canvas");
1434
+ scratchCanvas.width = width;
1435
+ scratchCanvas.height = height;
1436
+ return scratchCanvas.getContext("2d", { willReadFrequently: true });
1437
+ }
1438
+ function sourceSize(source) {
1439
+ if (typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) {
1440
+ return { width: source.width, height: source.height };
1441
+ }
1442
+ if (typeof HTMLCanvasElement !== "undefined" && source instanceof HTMLCanvasElement) {
1443
+ return { width: source.width, height: source.height };
1444
+ }
1445
+ if (typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) {
1446
+ return { width: source.width, height: source.height };
1447
+ }
1448
+ if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement) {
1449
+ return { width: source.naturalWidth, height: source.naturalHeight };
1450
+ }
1451
+ return null;
1452
+ }
1453
+ function sampleScanPaper(source, crop) {
1454
+ const scale = Math.min(1, SAMPLE_TARGET_PX / Math.max(crop.sw, crop.sh));
1455
+ const w = Math.max(1, Math.round(crop.sw * scale));
1456
+ const h = Math.max(1, Math.round(crop.sh * scale));
1457
+ const ctx = getScratchContext(w, h);
1458
+ if (!ctx) return void 0;
1459
+ try {
1460
+ ctx.drawImage(source, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, w, h);
1461
+ const { data } = ctx.getImageData(0, 0, w, h);
1462
+ const total = data.length / 4;
1463
+ let white = 0;
1464
+ let opaque = 0;
1465
+ let saturated = 0;
1466
+ let ink = 0;
1467
+ for (let i = 0; i < data.length; i += 4) {
1468
+ if (data[i + 3] < 250) continue;
1469
+ opaque++;
1470
+ const max = Math.max(data[i], data[i + 1], data[i + 2]);
1471
+ const min = Math.min(data[i], data[i + 1], data[i + 2]);
1472
+ if (min > 200 && max - min < 32) white++;
1473
+ else if (max > 60 && (max - min) / max > 0.35) saturated++;
1474
+ if (max < 100) ink++;
1475
+ }
1476
+ const isPaper = opaque / total >= SCAN_OPAQUE_MIN_FRACTION && white / total >= SCAN_WHITE_MIN_FRACTION && saturated / total <= SCAN_SATURATED_MAX_FRACTION;
1477
+ if (!isPaper) return null;
1478
+ return { inked: ink >= SCAN_INK_MIN_PIXELS };
1479
+ } catch {
1480
+ return void 0;
1481
+ }
1482
+ }
1483
+ function classifyScanPaper(source, crop) {
1484
+ const size = sourceSize(source);
1485
+ if (!size || size.width <= 0 || size.height <= 0) return null;
1486
+ const fullSource = !crop || crop.sx === 0 && crop.sy === 0 && crop.sw === size.width && crop.sh === size.height;
1487
+ const bitmap = fullSource && typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap ? source : null;
1488
+ if (bitmap) {
1489
+ const cached = bitmapVerdicts.get(bitmap);
1490
+ if (cached !== void 0) return cached;
1491
+ }
1492
+ const region = {
1493
+ ...crop ?? { sx: 0, sy: 0, sw: size.width, sh: size.height }
1494
+ };
1495
+ if (region.sw < 0) {
1496
+ region.sx += region.sw;
1497
+ region.sw = -region.sw;
1498
+ }
1499
+ if (region.sh < 0) {
1500
+ region.sy += region.sh;
1501
+ region.sh = -region.sh;
1502
+ }
1503
+ if (region.sw === 0 || region.sh === 0) return null;
1504
+ const verdict = sampleScanPaper(source, region);
1505
+ if (verdict === void 0) return null;
1506
+ if (bitmap) bitmapVerdicts.set(bitmap, verdict);
1507
+ return verdict;
1508
+ }
1509
+
1420
1510
  // src/lib/recolor-context.ts
1421
1511
  var RECOLOR_CLEANUP = Symbol("lectorRecolorCleanup");
1422
1512
  var RECOLOR_PAINTED = Symbol("lectorRecolorPainted");
@@ -1469,25 +1559,330 @@ function correctLuminosityMask(source, mappedWhiteLuma, mappedBlackLuma) {
1469
1559
  tmpCtx.putImageData(image, 0, 0);
1470
1560
  return tmp;
1471
1561
  }
1472
- function applyContextRecolor(ctx, map) {
1562
+ function applyContextRecolor(ctx, map, options) {
1473
1563
  const target = ctx;
1474
1564
  target[RECOLOR_CLEANUP]?.();
1475
1565
  const mappedWhiteLuma = parseHexLuma(map("#ffffff"));
1476
1566
  const mappedBlackLuma = parseHexLuma(map("#000000"));
1477
- const withMaskCorrection = (original) => function(...args) {
1567
+ const pageArea = options?.pageArea;
1568
+ const scanEnabled = !!pageArea && pageArea > 0 && mappedWhiteLuma !== null && mappedBlackLuma !== null;
1569
+ const scanInvertGray = scanEnabled ? 255 - Math.round(mappedWhiteLuma) : null;
1570
+ const scanTintCss = scanEnabled ? map("#ffffff") : null;
1571
+ let inkScaleGray = 255;
1572
+ let inkOffsetGray = 0;
1573
+ if (scanEnabled) {
1574
+ const inkLuma = 255 - mappedWhiteLuma;
1575
+ const span = inkLuma - mappedWhiteLuma;
1576
+ if (span > 8) {
1577
+ const scale = Math.min(
1578
+ 1,
1579
+ Math.max(0, (mappedBlackLuma - mappedWhiteLuma) / span)
1580
+ );
1581
+ inkScaleGray = Math.round(scale * 255);
1582
+ inkOffsetGray = Math.round(mappedWhiteLuma * (1 - scale));
1583
+ }
1584
+ }
1585
+ const needsInkRemap = inkScaleGray < 253 || inkOffsetGray > 2;
1586
+ const pristineFillRect = target.fillRect;
1587
+ const destRect = (args) => {
1588
+ if (args.length >= 9) {
1589
+ return [args[5], args[6], args[7], args[8]];
1590
+ }
1591
+ if (args.length >= 5) {
1592
+ return [args[1], args[2], args[3], args[4]];
1593
+ }
1594
+ const size = sourceSize(args[0]);
1595
+ if (!size) return null;
1596
+ return [
1597
+ args[1] ?? 0,
1598
+ args[2] ?? 0,
1599
+ size.width,
1600
+ size.height
1601
+ ];
1602
+ };
1603
+ const scanCandidate = (self, args) => {
1604
+ if (!scanEnabled || !pageArea) return null;
1605
+ if (self.globalCompositeOperation !== "source-over") return null;
1606
+ if (self.globalAlpha < 0.99) return null;
1607
+ const filter = self.filter;
1608
+ if (typeof filter === "string" && filter !== "none" && filter !== "")
1609
+ return null;
1610
+ const rect = destRect(args);
1611
+ if (!rect) return null;
1612
+ const t = self.getTransform();
1613
+ const painted = Math.abs(t.a * t.d - t.b * t.c) * Math.abs(rect[2] * rect[3]);
1614
+ const areaFraction = painted / pageArea;
1615
+ if (areaFraction < SCAN_TILE_MIN_FRACTION) return null;
1616
+ const crop = args.length >= 9 ? {
1617
+ sx: args[1],
1618
+ sy: args[2],
1619
+ sw: args[3],
1620
+ sh: args[4]
1621
+ } : void 0;
1622
+ const paper = classifyScanPaper(args[0], crop);
1623
+ if (!paper) return null;
1624
+ return { rect, areaFraction, clipped: clipActive(), inked: paper.inked };
1625
+ };
1626
+ const grayCss = (value) => `rgb(${value}, ${value}, ${value})`;
1627
+ const clipFlags = [false];
1628
+ const clipActive = () => clipFlags[clipFlags.length - 1] === true;
1629
+ const pristineSave = target.save;
1630
+ const pristineRestore = target.restore;
1631
+ const pristineClip = target.clip;
1632
+ const invertScanRect = (self, rect, matrix) => {
1633
+ self.save();
1634
+ self.setTransform(matrix);
1635
+ self.globalAlpha = 1;
1636
+ self.shadowColor = "rgba(0, 0, 0, 0)";
1637
+ self.shadowBlur = 0;
1638
+ self.shadowOffsetX = 0;
1639
+ self.shadowOffsetY = 0;
1640
+ self.globalCompositeOperation = "difference";
1641
+ self.fillStyle = grayCss(scanInvertGray ?? 0);
1642
+ pristineFillRect.call(self, rect[0], rect[1], rect[2], rect[3]);
1643
+ if (needsInkRemap) {
1644
+ self.globalCompositeOperation = "multiply";
1645
+ self.fillStyle = grayCss(inkScaleGray);
1646
+ pristineFillRect.call(self, rect[0], rect[1], rect[2], rect[3]);
1647
+ if (inkOffsetGray > 0) {
1648
+ self.globalCompositeOperation = "lighter";
1649
+ self.fillStyle = grayCss(inkOffsetGray);
1650
+ pristineFillRect.call(self, rect[0], rect[1], rect[2], rect[3]);
1651
+ }
1652
+ }
1653
+ if (scanTintCss) {
1654
+ self.globalCompositeOperation = "color";
1655
+ self.fillStyle = scanTintCss;
1656
+ pristineFillRect.call(self, rect[0], rect[1], rect[2], rect[3]);
1657
+ }
1658
+ self.restore();
1659
+ };
1660
+ const OVERLAP_TOLERANCE = 0.2;
1661
+ let pendingTileArea = 0;
1662
+ let pendingHasInk = false;
1663
+ const pendingTiles = [];
1664
+ const coveredBounds = [];
1665
+ let paintSerial = 0;
1666
+ let pendingBaselineSerial = 0;
1667
+ const tileBounds = (matrix, rect) => {
1668
+ const corners = [
1669
+ matrix.transformPoint({ x: rect[0], y: rect[1] }),
1670
+ matrix.transformPoint({ x: rect[0] + rect[2], y: rect[1] }),
1671
+ matrix.transformPoint({ x: rect[0], y: rect[1] + rect[3] }),
1672
+ matrix.transformPoint({ x: rect[0] + rect[2], y: rect[1] + rect[3] })
1673
+ ];
1674
+ return [
1675
+ Math.min(...corners.map((c) => c.x)),
1676
+ Math.min(...corners.map((c) => c.y)),
1677
+ Math.max(...corners.map((c) => c.x)),
1678
+ Math.max(...corners.map((c) => c.y))
1679
+ ];
1680
+ };
1681
+ const boundsArea = (b) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
1682
+ const overlapsAny = (bounds, others) => others.some((other) => {
1683
+ const intersection = [
1684
+ Math.max(bounds[0], other[0]),
1685
+ Math.max(bounds[1], other[1]),
1686
+ Math.min(bounds[2], other[2]),
1687
+ Math.min(bounds[3], other[3])
1688
+ ];
1689
+ const smaller = Math.min(boundsArea(bounds), boundsArea(other));
1690
+ return smaller > 0 && boundsArea(intersection) > OVERLAP_TOLERANCE * smaller;
1691
+ });
1692
+ const AXIS_EPS = 1e-6;
1693
+ const isAxisAligned = (m) => Math.abs(m.b) < AXIS_EPS && Math.abs(m.c) < AXIS_EPS;
1694
+ const IDENTITY_MATRIX = typeof DOMMatrix !== "undefined" ? new DOMMatrix() : null;
1695
+ const subtractBounds = (rects, hole) => {
1696
+ const out = [];
1697
+ for (const r2 of rects) {
1698
+ const ix0 = Math.max(r2[0], hole[0]);
1699
+ const iy0 = Math.max(r2[1], hole[1]);
1700
+ const ix1 = Math.min(r2[2], hole[2]);
1701
+ const iy1 = Math.min(r2[3], hole[3]);
1702
+ if (ix0 >= ix1 || iy0 >= iy1) {
1703
+ out.push(r2);
1704
+ continue;
1705
+ }
1706
+ if (r2[1] < iy0) out.push([r2[0], r2[1], r2[2], iy0]);
1707
+ if (iy1 < r2[3]) out.push([r2[0], iy1, r2[2], r2[3]]);
1708
+ if (r2[0] < ix0) out.push([r2[0], iy0, ix0, iy1]);
1709
+ if (ix1 < r2[2]) out.push([ix1, iy0, r2[2], iy1]);
1710
+ }
1711
+ return out;
1712
+ };
1713
+ const invertTileRemainder = (self, tile, holes) => {
1714
+ if (!isAxisAligned(tile.matrix) || !IDENTITY_MATRIX) {
1715
+ invertScanRect(self, tile.rect, tile.matrix);
1716
+ return;
1717
+ }
1718
+ let pieces = [tile.bounds];
1719
+ for (const hole of holes) pieces = subtractBounds(pieces, hole);
1720
+ for (const piece of pieces) {
1721
+ if (piece[2] - piece[0] < 0.5 || piece[3] - piece[1] < 0.5) continue;
1722
+ invertScanRect(
1723
+ self,
1724
+ [piece[0], piece[1], piece[2] - piece[0], piece[3] - piece[1]],
1725
+ IDENTITY_MATRIX
1726
+ );
1727
+ }
1728
+ };
1729
+ const pendingTriggerReady = (requireInk = true) => {
1730
+ if (pendingTileArea < SCAN_COVERAGE_MIN) return false;
1731
+ if (requireInk && !pendingHasInk) return false;
1732
+ const union = [
1733
+ Math.min(...pendingTiles.map((t) => t.bounds[0])),
1734
+ Math.min(...pendingTiles.map((t) => t.bounds[1])),
1735
+ Math.max(...pendingTiles.map((t) => t.bounds[2])),
1736
+ Math.max(...pendingTiles.map((t) => t.bounds[3]))
1737
+ ];
1738
+ const unionArea = boundsArea(union);
1739
+ if (unionArea <= 0) return false;
1740
+ return pendingTileArea * (pageArea ?? 0) / unionArea >= SCAN_TILE_DENSITY_MIN;
1741
+ };
1742
+ const prunePendingToCleanSuffix = () => {
1743
+ if (paintSerial === pendingBaselineSerial) return;
1744
+ const kept = pendingTiles.filter((t) => t.serial === paintSerial);
1745
+ pendingTiles.length = 0;
1746
+ pendingTiles.push(...kept);
1747
+ pendingTileArea = kept.reduce((sum, t) => sum + t.area, 0);
1748
+ pendingHasInk = kept.some((t) => t.inked);
1749
+ pendingBaselineSerial = paintSerial;
1750
+ };
1751
+ const flushPendingTiles = (self) => {
1752
+ if (paintSerial !== pendingBaselineSerial) {
1753
+ prunePendingToCleanSuffix();
1754
+ if (pendingTiles.length === 0 || !pendingTriggerReady()) return;
1755
+ }
1756
+ for (const pending of pendingTiles) {
1757
+ invertTileRemainder(self, pending, coveredBounds);
1758
+ coveredBounds.push(pending.bounds);
1759
+ }
1760
+ pendingTiles.length = 0;
1761
+ pendingTileArea = 0;
1762
+ pendingHasInk = false;
1763
+ };
1764
+ const handleScanCandidate = (self, candidate) => {
1765
+ const matrix = self.getTransform();
1766
+ const bounds = tileBounds(matrix, candidate.rect);
1767
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN && !candidate.clipped) {
1768
+ for (let i = coveredBounds.length - 1; i >= 0; i--) {
1769
+ const covered = coveredBounds[i];
1770
+ const intersection = [
1771
+ Math.max(bounds[0], covered[0]),
1772
+ Math.max(bounds[1], covered[1]),
1773
+ Math.min(bounds[2], covered[2]),
1774
+ Math.min(bounds[3], covered[3])
1775
+ ];
1776
+ const coveredArea = boundsArea(covered);
1777
+ if (coveredArea > 0 && boundsArea(intersection) >= 0.8 * coveredArea) {
1778
+ coveredBounds.splice(i, 1);
1779
+ }
1780
+ }
1781
+ }
1782
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN && candidate.clipped) {
1783
+ if (candidate.inked) {
1784
+ invertScanRect(self, candidate.rect, matrix);
1785
+ }
1786
+ pendingTiles.length = 0;
1787
+ pendingTileArea = 0;
1788
+ pendingHasInk = false;
1789
+ return;
1790
+ }
1791
+ if (coveredBounds.length > 0 && candidate.areaFraction <= 0.2) {
1792
+ const candidateArea = boundsArea(bounds);
1793
+ let insideArea = 0;
1794
+ for (const covered of coveredBounds) {
1795
+ const intersection = [
1796
+ Math.max(bounds[0], covered[0]),
1797
+ Math.max(bounds[1], covered[1]),
1798
+ Math.min(bounds[2], covered[2]),
1799
+ Math.min(bounds[3], covered[3])
1800
+ ];
1801
+ insideArea += boundsArea(intersection);
1802
+ }
1803
+ if (candidateArea > 0 && insideArea >= 0.8 * candidateArea) return;
1804
+ }
1805
+ if (coveredBounds.length > 0 && !candidate.clipped) {
1806
+ invertScanRect(self, candidate.rect, matrix);
1807
+ coveredBounds.push(bounds);
1808
+ return;
1809
+ }
1810
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN && candidate.inked) {
1811
+ invertScanRect(self, candidate.rect, matrix);
1812
+ coveredBounds.push(bounds);
1813
+ flushPendingTiles(self);
1814
+ return;
1815
+ }
1816
+ if (candidate.clipped) {
1817
+ paintSerial++;
1818
+ return;
1819
+ }
1820
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN) {
1821
+ for (let i = pendingTiles.length - 1; i >= 0; i--) {
1822
+ const pending = pendingTiles[i];
1823
+ const intersection = [
1824
+ Math.max(bounds[0], pending.bounds[0]),
1825
+ Math.max(bounds[1], pending.bounds[1]),
1826
+ Math.min(bounds[2], pending.bounds[2]),
1827
+ Math.min(bounds[3], pending.bounds[3])
1828
+ ];
1829
+ const pendingArea = boundsArea(pending.bounds);
1830
+ if (pendingArea > 0 && boundsArea(intersection) >= 0.8 * pendingArea) {
1831
+ pendingTiles.splice(i, 1);
1832
+ }
1833
+ }
1834
+ pendingTileArea = pendingTiles.reduce((sum, t) => sum + t.area, 0);
1835
+ pendingHasInk = pendingTiles.some((t) => t.inked);
1836
+ }
1837
+ let contributedArea = candidate.areaFraction;
1838
+ if (isAxisAligned(matrix) && pageArea) {
1839
+ let pieces = [bounds];
1840
+ for (const pending of pendingTiles) {
1841
+ pieces = subtractBounds(pieces, pending.bounds);
1842
+ }
1843
+ contributedArea = pieces.reduce((sum, piece) => sum + boundsArea(piece), 0) / pageArea;
1844
+ } else if (overlapsAny(
1845
+ bounds,
1846
+ pendingTiles.map((t) => t.bounds)
1847
+ )) {
1848
+ return;
1849
+ }
1850
+ if (pendingTiles.length === 0) pendingBaselineSerial = paintSerial;
1851
+ pendingTiles.push({
1852
+ rect: candidate.rect,
1853
+ matrix,
1854
+ bounds,
1855
+ area: contributedArea,
1856
+ inked: candidate.inked,
1857
+ serial: paintSerial
1858
+ });
1859
+ pendingTileArea += contributedArea;
1860
+ pendingHasInk ||= candidate.inked;
1861
+ if (!pendingTriggerReady()) return;
1862
+ flushPendingTiles(self);
1863
+ };
1864
+ const withImageHandling = (original) => function(...args) {
1478
1865
  if (mappedWhiteLuma !== null && mappedBlackLuma !== null && this.globalCompositeOperation === "destination-in" && typeof this.filter === "string" && this.filter.includes("luminosity")) {
1479
1866
  const source = args[0];
1480
1867
  const sourcePainted = (typeof HTMLCanvasElement !== "undefined" && source instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) && source.getContext("2d")?.[RECOLOR_PAINTED] === true;
1481
1868
  const corrected = sourcePainted ? correctLuminosityMask(source, mappedWhiteLuma, mappedBlackLuma) : null;
1869
+ paintSerial++;
1482
1870
  if (corrected) {
1483
1871
  const next = [corrected, ...args.slice(1)];
1484
1872
  return original.apply(this, next);
1485
1873
  }
1874
+ return original.apply(this, args);
1486
1875
  }
1487
- return original.apply(this, args);
1876
+ const candidate = scanCandidate(this, args);
1877
+ if (!candidate) paintSerial++;
1878
+ const result = original.apply(this, args);
1879
+ if (candidate) handleScanCandidate(this, candidate);
1880
+ return result;
1488
1881
  };
1489
1882
  const withMappedStyle = (original, styleProp) => function(...args) {
1490
1883
  const style = this[styleProp];
1884
+ const invisible = this.globalCompositeOperation === "source-over" && (this.globalAlpha === 0 || style === "transparent" || typeof style === "string" && style.startsWith("rgba(") && style.endsWith(", 0)"));
1885
+ if (!invisible) paintSerial++;
1491
1886
  if (typeof style === "string") {
1492
1887
  const mapped = map(style);
1493
1888
  if (mapped !== style) {
@@ -1520,14 +1915,55 @@ function applyContextRecolor(ctx, map) {
1520
1915
  record[name] = withMappedStyle(target[name], "strokeStyle");
1521
1916
  for (const name of GRADIENT_METHODS)
1522
1917
  record[name] = withMappedStops(target[name]);
1523
- record.drawImage = withMaskCorrection(target.drawImage);
1524
- const cleanup = () => {
1918
+ record.drawImage = withImageHandling(target.drawImage);
1919
+ const pristinePutImageData = target.putImageData;
1920
+ record.putImageData = function(...args) {
1921
+ paintSerial++;
1922
+ return pristinePutImageData.apply(this, args);
1923
+ };
1924
+ const pristineClearRect = target.clearRect;
1925
+ record.clearRect = function(...args) {
1926
+ paintSerial++;
1927
+ return pristineClearRect.apply(this, args);
1928
+ };
1929
+ record.save = function() {
1930
+ clipFlags.push(clipFlags[clipFlags.length - 1]);
1931
+ return pristineSave.call(this);
1932
+ };
1933
+ record.restore = function() {
1934
+ if (clipFlags.length > 1) clipFlags.pop();
1935
+ return pristineRestore.call(this);
1936
+ };
1937
+ record.clip = function(...args) {
1938
+ clipFlags[clipFlags.length - 1] = true;
1939
+ return pristineClip.apply(this, args);
1940
+ };
1941
+ const finalizeBlankScanPage = () => {
1942
+ if (pendingTiles.length === 0) return;
1943
+ prunePendingToCleanSuffix();
1944
+ if (pendingTiles.length === 0) return;
1945
+ if (!pendingTriggerReady(false)) return;
1946
+ for (const pending of pendingTiles) {
1947
+ invertTileRemainder(target, pending, coveredBounds);
1948
+ coveredBounds.push(pending.bounds);
1949
+ }
1950
+ pendingTiles.length = 0;
1951
+ pendingTileArea = 0;
1952
+ pendingHasInk = false;
1953
+ };
1954
+ const cleanup = (finalizeRender = false) => {
1525
1955
  if (target[RECOLOR_CLEANUP] !== cleanup) return;
1956
+ if (finalizeRender) finalizeBlankScanPage();
1526
1957
  for (const name of [
1527
1958
  ...FILL_METHODS,
1528
1959
  ...STROKE_METHODS,
1529
1960
  ...GRADIENT_METHODS,
1530
- "drawImage"
1961
+ "drawImage",
1962
+ "putImageData",
1963
+ "clearRect",
1964
+ "save",
1965
+ "restore",
1966
+ "clip"
1531
1967
  ])
1532
1968
  delete record[name];
1533
1969
  delete target[RECOLOR_CLEANUP];
@@ -1792,7 +2228,9 @@ var useCanvasLayer = ({ background }) => {
1792
2228
  };
1793
2229
  let restoreRecolor = null;
1794
2230
  if (recolor) {
1795
- restoreRecolor = applyContextRecolor(renderCtx, recolor);
2231
+ restoreRecolor = applyContextRecolor(renderCtx, recolor, {
2232
+ pageArea: viewport.width * viewport.height
2233
+ });
1796
2234
  } else {
1797
2235
  removeContextRecolor(renderCtx);
1798
2236
  }
@@ -1809,19 +2247,20 @@ var useCanvasLayer = ({ background }) => {
1809
2247
  releaseBuffer();
1810
2248
  throw error;
1811
2249
  }
1812
- renderingTask.promise.finally(() => {
1813
- restoreRecolor?.();
1814
- }).then(() => {
2250
+ renderingTask.promise.then(() => {
1815
2251
  if (cancelled) {
2252
+ restoreRecolor?.();
1816
2253
  releaseBuffer();
1817
2254
  return;
1818
2255
  }
1819
2256
  const state = store.getState();
1820
2257
  const currentRecolorKey = state.colorScheme === "dark" ? `dark:${state.darkModeColors.background},${state.darkModeColors.foreground}` : void 0;
1821
2258
  if (currentRecolorKey !== recolorKey) {
2259
+ restoreRecolor?.();
1822
2260
  releaseBuffer();
1823
2261
  return;
1824
2262
  }
2263
+ restoreRecolor?.(true);
1825
2264
  if (useBuffer) {
1826
2265
  applyBackingSize();
1827
2266
  context.drawImage(buffer, 0, 0);
@@ -1853,6 +2292,7 @@ var useCanvasLayer = ({ background }) => {
1853
2292
  releaseBuffer();
1854
2293
  }
1855
2294
  }).catch((error) => {
2295
+ restoreRecolor?.();
1856
2296
  releaseBuffer();
1857
2297
  if (cancelled) return;
1858
2298
  if (error?.name === "RenderingCancelledException") return;
@@ -2165,7 +2605,9 @@ var useDetailCanvasLayer = ({
2165
2605
  const detailViewport = pdfPageProxy.getViewport({
2166
2606
  scale: effectiveScale
2167
2607
  });
2168
- if (recolor) applyContextRecolor(bufferCtx, recolor);
2608
+ const restoreRecolor = recolor ? applyContextRecolor(bufferCtx, recolor, {
2609
+ pageArea: detailViewport.width * detailViewport.height
2610
+ }) : null;
2169
2611
  const releaseBuffer = () => {
2170
2612
  buffer.width = 0;
2171
2613
  buffer.height = 0;
@@ -2193,6 +2635,7 @@ var useDetailCanvasLayer = ({
2193
2635
  const state = store.getState();
2194
2636
  const currentRecolorKey = state.colorScheme === "dark" ? `dark:${state.darkModeColors.background},${state.darkModeColors.foreground}` : "light";
2195
2637
  if (currentRecolorKey !== recolorKey) return;
2638
+ restoreRecolor?.(true);
2196
2639
  detailCanvas.width = actualWidth;
2197
2640
  detailCanvas.height = actualHeight;
2198
2641
  detailCanvas.style.cssText = `${detailBaseStyle};display:block;opacity:1;width:${cssWidth}px;height:${cssHeight}px;transform-origin:center center;transform:translate(${rectLeft * zoom}px,${rectTop * zoom}px)`;
@@ -2264,6 +2707,7 @@ var useDetailCanvasLayer = ({
2264
2707
  clearTimeout(renderTimeoutId);
2265
2708
  }
2266
2709
  void renderingTask?.cancel();
2710
+ renderingTask = null;
2267
2711
  };
2268
2712
  }, [
2269
2713
  pdfPageProxy,
@@ -6651,6 +7095,7 @@ var useThumbnail = (pageNumber, config = {}) => {
6651
7095
  useEffect(() => {
6652
7096
  if (!isVisible && renderedProxyRef.current !== pageProxy) return;
6653
7097
  renderedProxyRef.current = pageProxy;
7098
+ let stale = false;
6654
7099
  const renderThumbnail = async () => {
6655
7100
  const canvas2 = canvasRef.current;
6656
7101
  if (!canvas2 || !pageProxy) return;
@@ -6671,7 +7116,9 @@ var useThumbnail = (pageNumber, config = {}) => {
6671
7116
  if (!context) return;
6672
7117
  let restoreRecolor = null;
6673
7118
  if (recolor) {
6674
- restoreRecolor = applyContextRecolor(context, recolor);
7119
+ restoreRecolor = applyContextRecolor(context, recolor, {
7120
+ pageArea: scaledViewport.width * scaledViewport.height
7121
+ });
6675
7122
  } else {
6676
7123
  removeContextRecolor(context);
6677
7124
  }
@@ -6683,6 +7130,7 @@ var useThumbnail = (pageNumber, config = {}) => {
6683
7130
  });
6684
7131
  renderTaskRef.current = renderTask;
6685
7132
  await renderTask.promise;
7133
+ restoreRecolor?.(!stale);
6686
7134
  } finally {
6687
7135
  restoreRecolor?.();
6688
7136
  }
@@ -6697,6 +7145,7 @@ var useThumbnail = (pageNumber, config = {}) => {
6697
7145
  renderThumbnail();
6698
7146
  const canvas = canvasRef.current;
6699
7147
  return () => {
7148
+ stale = true;
6700
7149
  renderTaskRef.current?.cancel();
6701
7150
  if (canvas) {
6702
7151
  canvas.width = 1;
package/package.json CHANGED
@@ -1,103 +1,104 @@
1
1
  {
2
- "name": "@anaralabs/lector",
3
- "version": "3.13.4",
4
- "description": "A headless PDF viewer for React",
5
- "author": "andrewdr (https://github.com/andrewdoro)",
6
- "license": "MIT",
7
- "type": "module",
8
- "sideEffects": false,
9
- "module": "dist/index.js",
10
- "types": "dist/index.d.ts",
11
- "exports": {
12
- ".": {
13
- "types": "./dist/index.d.ts",
14
- "import": "./dist/index.js",
15
- "require": "./esm-only.cjs"
16
- }
17
- },
18
- "files": [
19
- "dist"
20
- ],
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
- "scripts": {
25
- "dev": "tsup --watch --onSuccess 'yalc push'",
26
- "prebuild": "rm -rf dist",
27
- "build": "tsup",
28
- "lint": "biome check .",
29
- "format": "biome format --write .",
30
- "postbuild": "size-limit --json > size.json",
31
- "test": "pnpm run '/^test:/'",
32
- "test:unit": "vitest run",
33
- "test:size": "size-limit",
34
- "prepack": "./scripts/prepack.sh"
35
- },
36
- "devDependencies": {
37
- "@microsoft/api-extractor": "^7.48.1",
38
- "@size-limit/preset-small-lib": "^11.1.6",
39
- "@testing-library/react": "^16.0.0",
40
- "@types/node": "^22.9.0",
41
- "@types/react": "^19.0.1",
42
- "@types/react-dom": "^19.0.2",
43
- "@use-gesture/react": "^10.3.1",
44
- "@vitejs/plugin-react": "^4.3.4",
45
- "@vitest/browser": "^3.2.6",
46
- "clsx": "^2.1.1",
47
- "pdfjs-dist": "^5.5.207",
48
- "playwright": "^1.45.3",
49
- "react": "^19.1.1",
50
- "size-limit": "^11.1.6",
51
- "tsc-alias": "^1.8.10",
52
- "tslib": "^2.6.3",
53
- "tsup": "^8.3.5",
54
- "typescript": "^5.5.3",
55
- "vite": "^6.4.3",
56
- "vitest": "^3.2.6",
57
- "webdriverio": "^8.39.1"
58
- },
59
- "peerDependencies": {
60
- "pdfjs-dist": "^5.5.207",
61
- "react": ">=19"
62
- },
63
- "dependencies": {
64
- "@floating-ui/react": "^0.26.28",
65
- "@radix-ui/react-slot": "^1.1.0",
66
- "@tanstack/react-virtual": "^3.10.9",
67
- "react-dom": "^19.1.1",
68
- "use-debounce": "^10.0.4",
69
- "uuid": "^11.1.1",
70
- "zustand": "^5.0.2"
71
- },
72
- "optionalDependencies": {
73
- "@rollup/rollup-linux-x64-gnu": "^4.28.1"
74
- },
75
- "keywords": [
76
- "pdf",
77
- "react",
78
- "headless",
79
- "viewer",
80
- "react-pdf",
81
- "anaralabs",
82
- "typescript"
83
- ],
84
- "repository": {
85
- "type": "git",
86
- "url": "git+https://github.com/anaralabs/lector.git",
87
- "directory": "packages/lector"
88
- },
89
- "bugs": {
90
- "url": "https://github.com/anaralabs/lector/issues"
91
- },
92
- "homepage": "https://github.com/anaralabs/lector",
93
- "size-limit": [
94
- {
95
- "name": "Client",
96
- "path": "dist/index.js",
97
- "limit": "150 kB",
98
- "ignore": [
99
- "react"
100
- ]
101
- }
102
- ]
2
+ "name": "@anaralabs/lector",
3
+ "version": "3.14.1",
4
+ "description": "A headless PDF viewer for React",
5
+ "author": "andrewdr (https://github.com/andrewdoro)",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "module": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./esm-only.cjs"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "dev": "tsup --watch --onSuccess 'yalc push'",
26
+ "prebuild": "rm -rf dist",
27
+ "build": "tsup",
28
+ "lint": "biome check .",
29
+ "format": "biome format --write .",
30
+ "postbuild": "size-limit --json > size.json",
31
+ "test": "pnpm run '/^test:/'",
32
+ "test:unit": "vitest run",
33
+ "test:size": "size-limit",
34
+ "prepack": "./scripts/prepack.sh",
35
+ "prepare": "tsup"
36
+ },
37
+ "devDependencies": {
38
+ "@microsoft/api-extractor": "^7.48.1",
39
+ "@size-limit/preset-small-lib": "^11.1.6",
40
+ "@testing-library/react": "^16.0.0",
41
+ "@types/node": "^22.9.0",
42
+ "@types/react": "^19.0.1",
43
+ "@types/react-dom": "^19.0.2",
44
+ "@use-gesture/react": "^10.3.1",
45
+ "@vitejs/plugin-react": "^4.3.4",
46
+ "@vitest/browser": "^3.2.6",
47
+ "clsx": "^2.1.1",
48
+ "pdfjs-dist": "^5.5.207",
49
+ "playwright": "^1.45.3",
50
+ "react": "^19.1.1",
51
+ "size-limit": "^11.1.6",
52
+ "tsc-alias": "^1.8.10",
53
+ "tslib": "^2.6.3",
54
+ "tsup": "^8.3.5",
55
+ "typescript": "^5.5.3",
56
+ "vite": "^6.4.3",
57
+ "vitest": "^3.2.6",
58
+ "webdriverio": "^8.39.1"
59
+ },
60
+ "peerDependencies": {
61
+ "pdfjs-dist": "^5.5.207",
62
+ "react": ">=19"
63
+ },
64
+ "dependencies": {
65
+ "@floating-ui/react": "^0.26.28",
66
+ "@radix-ui/react-slot": "^1.1.0",
67
+ "@tanstack/react-virtual": "^3.10.9",
68
+ "react-dom": "^19.1.1",
69
+ "use-debounce": "^10.0.4",
70
+ "uuid": "^11.1.1",
71
+ "zustand": "^5.0.2"
72
+ },
73
+ "optionalDependencies": {
74
+ "@rollup/rollup-linux-x64-gnu": "^4.28.1"
75
+ },
76
+ "keywords": [
77
+ "pdf",
78
+ "react",
79
+ "headless",
80
+ "viewer",
81
+ "react-pdf",
82
+ "anaralabs",
83
+ "typescript"
84
+ ],
85
+ "repository": {
86
+ "type": "git",
87
+ "url": "git+https://github.com/anaralabs/lector.git",
88
+ "directory": "packages/lector"
89
+ },
90
+ "bugs": {
91
+ "url": "https://github.com/anaralabs/lector/issues"
92
+ },
93
+ "homepage": "https://github.com/anaralabs/lector",
94
+ "size-limit": [
95
+ {
96
+ "name": "Client",
97
+ "path": "dist/index.js",
98
+ "limit": "150 kB",
99
+ "ignore": [
100
+ "react"
101
+ ]
102
+ }
103
+ ]
103
104
  }