@anaralabs/lector 3.13.4 → 3.14.0

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,18 @@ 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
150
  * never modified). Returns a cleanup that restores the pristine context.
149
151
  */
150
- export declare function applyContextRecolor(ctx: CanvasRenderingContext2D, map: RenderColorMap): () => void;
152
+ export declare function applyContextRecolor(ctx: CanvasRenderingContext2D, map: RenderColorMap, options?: RecolorContextOptions): () => void;
151
153
 
152
154
  /**
153
155
  * Calculates the highlight rectangles for a given text match.
@@ -183,6 +185,8 @@ export declare const clamp: (value: number, minimum: number, maximum: number) =>
183
185
 
184
186
  export declare function clampScaleForPage(targetScale: number, pageWidth: number, pageHeight: number, maxPixels?: number): number;
185
187
 
188
+ export declare function classifyScanPaper(source: CanvasImageSource, crop?: SourceCrop): ScanPaperClass | null;
189
+
186
190
  declare type CollapsibleSelection = {
187
191
  highlights: HighlightRect[];
188
192
  underlines?: HighlightRect[];
@@ -331,6 +335,8 @@ export declare interface InitialPDFState {
331
335
 
332
336
  export declare const IS_MOBILE_DEVICE: boolean;
333
337
 
338
+ export declare function isScanPaperSource(source: CanvasImageSource, crop?: SourceCrop): boolean;
339
+
334
340
  export declare class LinkService {
335
341
  _pdfDocumentProxy?: PDFDocumentProxy;
336
342
  externalLinkEnabled: boolean;
@@ -463,6 +469,18 @@ export declare type PDFVirtualizer = Virtualizer<any, any>;
463
469
 
464
470
  export declare const PreviousPage: () => void;
465
471
 
472
+ export declare interface RecolorContextOptions {
473
+ /**
474
+ * Full-page area in the context's device space (viewport.width × height).
475
+ * When provided, image draws that paper the page (≥ SCAN_COVERAGE_MIN of
476
+ * this area) with near-white pixels — scanned pages — are inverted toward
477
+ * the palette poles. Omit on pdf.js scratch canvases, whose device space
478
+ * is not the page; a page-covering scan painted through one is still
479
+ * caught when the scratch canvas is composed back onto a page context.
480
+ */
481
+ pageArea?: number;
482
+ }
483
+
466
484
  /**
467
485
  * Removes any recolor wrapper installed on the context. For long-lived
468
486
  * contexts (the visible canvas in the no-buffer fallback, thumbnails) that
@@ -488,6 +506,60 @@ export declare const Root: ForwardRefExoticComponent<Omit<Omit<HTMLProps<HTMLDiv
488
506
  loader?: ReactNode;
489
507
  }, "ref"> & RefAttributes<unknown>>;
490
508
 
509
+ /** Painted area relative to the page above which a draw "papers the page". */
510
+ export declare const SCAN_COVERAGE_MIN = 0.7;
511
+
512
+ /**
513
+ * Minimum count of dark "ink" pixels in the downsample. A pure-white raster
514
+ * is an MRC background layer (its ink lives in a separate dark layer that
515
+ * must stay un-inverted to remain readable) or a blank page — skip both.
516
+ * A single genuine dark sample suffices so sparse pages (a signature, a few
517
+ * lines) still qualify; downsample averaging already erases isolated noise.
518
+ */
519
+ export declare const SCAN_INK_MIN_PIXELS = 1;
520
+
521
+ /**
522
+ * Minimum opaque fraction: scans are fully opaque; a cut-out PNG would let
523
+ * the inversion fill bleed into its transparent holes.
524
+ */
525
+ export declare const SCAN_OPAQUE_MIN_FRACTION = 0.99;
526
+
527
+ /**
528
+ * Maximum saturated fraction. Stamps and seals on real scans measure a few
529
+ * percent saturated; a rasterized slide with a colored chart measures far
530
+ * more — inverting it would render the chart as a negative.
531
+ */
532
+ export declare const SCAN_SATURATED_MAX_FRACTION = 0.15;
533
+
534
+ /**
535
+ * Minimum density (summed tile area / union bounding box area) for pending
536
+ * tiles to read as one contiguous scan: strips partitioning a page are dense,
537
+ * scattered white screenshots on a text page are not.
538
+ */
539
+ export declare const SCAN_TILE_DENSITY_MIN = 0.85;
540
+
541
+ /**
542
+ * Minimum painted fraction for a draw to count as a scan tile: some raster
543
+ * PDFs encode a page as strips or grids (8×8 grids put single tiles under
544
+ * 2%) whose areas only paper the page in sum. The floor only excludes
545
+ * degenerate sliver spam — the density, saturation, page-level ink and
546
+ * paint-serial guards do the real false-positive filtering.
547
+ */
548
+ export declare const SCAN_TILE_MIN_FRACTION = 0.005;
549
+
550
+ /** Fraction of near-white opaque pixels above which a source is scan paper. */
551
+ export declare const SCAN_WHITE_MIN_FRACTION = 0.6;
552
+
553
+ export declare interface ScanPaperClass {
554
+ /**
555
+ * Whether the paper carries dark ink pixels. A pure-white raster is an
556
+ * MRC background layer (its ink lives in a separate dark layer that must
557
+ * stay readable) or a blank margin tile — pages only invert when ink is
558
+ * present somewhere, but blank tiles still count toward tiled coverage.
559
+ */
560
+ inked: boolean;
561
+ }
562
+
491
563
  export declare const Search: ({ children, loading }: SearchProps) => ReactNode;
492
564
 
493
565
  declare interface SearchOptions {
@@ -524,6 +596,18 @@ declare interface SelectionTooltipProps {
524
596
 
525
597
  export declare type Source = string | URL | TypedArray | ArrayBuffer | DocumentInitParameters;
526
598
 
599
+ export declare interface SourceCrop {
600
+ sx: number;
601
+ sy: number;
602
+ sw: number;
603
+ sh: number;
604
+ }
605
+
606
+ export declare function sourceSize(source: CanvasImageSource): {
607
+ width: number;
608
+ height: number;
609
+ } | null;
610
+
527
611
  export declare const subscribeToViewportInvalidation: (viewport: HTMLDivElement, callback: ViewportCallback) => () => void;
528
612
 
529
613
  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,325 @@ 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 = () => {
1730
+ if (pendingTileArea < SCAN_COVERAGE_MIN || !pendingHasInk) return false;
1731
+ const union = [
1732
+ Math.min(...pendingTiles.map((t) => t.bounds[0])),
1733
+ Math.min(...pendingTiles.map((t) => t.bounds[1])),
1734
+ Math.max(...pendingTiles.map((t) => t.bounds[2])),
1735
+ Math.max(...pendingTiles.map((t) => t.bounds[3]))
1736
+ ];
1737
+ const unionArea = boundsArea(union);
1738
+ if (unionArea <= 0) return false;
1739
+ return pendingTileArea * (pageArea ?? 0) / unionArea >= SCAN_TILE_DENSITY_MIN;
1740
+ };
1741
+ const flushPendingTiles = (self) => {
1742
+ if (paintSerial !== pendingBaselineSerial) {
1743
+ const kept = pendingTiles.filter((t) => t.serial === paintSerial);
1744
+ pendingTiles.length = 0;
1745
+ pendingTiles.push(...kept);
1746
+ pendingTileArea = kept.reduce((sum, t) => sum + t.area, 0);
1747
+ pendingHasInk = kept.some((t) => t.inked);
1748
+ pendingBaselineSerial = paintSerial;
1749
+ if (pendingTiles.length === 0 || !pendingTriggerReady()) return;
1750
+ }
1751
+ for (const pending of pendingTiles) {
1752
+ invertTileRemainder(self, pending, coveredBounds);
1753
+ coveredBounds.push(pending.bounds);
1754
+ }
1755
+ pendingTiles.length = 0;
1756
+ pendingTileArea = 0;
1757
+ pendingHasInk = false;
1758
+ };
1759
+ const handleScanCandidate = (self, candidate) => {
1760
+ const matrix = self.getTransform();
1761
+ const bounds = tileBounds(matrix, candidate.rect);
1762
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN && !candidate.clipped) {
1763
+ for (let i = coveredBounds.length - 1; i >= 0; i--) {
1764
+ const covered = coveredBounds[i];
1765
+ const intersection = [
1766
+ Math.max(bounds[0], covered[0]),
1767
+ Math.max(bounds[1], covered[1]),
1768
+ Math.min(bounds[2], covered[2]),
1769
+ Math.min(bounds[3], covered[3])
1770
+ ];
1771
+ const coveredArea = boundsArea(covered);
1772
+ if (coveredArea > 0 && boundsArea(intersection) >= 0.8 * coveredArea) {
1773
+ coveredBounds.splice(i, 1);
1774
+ }
1775
+ }
1776
+ }
1777
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN && candidate.clipped) {
1778
+ if (candidate.inked) {
1779
+ invertScanRect(self, candidate.rect, matrix);
1780
+ }
1781
+ pendingTiles.length = 0;
1782
+ pendingTileArea = 0;
1783
+ pendingHasInk = false;
1784
+ return;
1785
+ }
1786
+ if (coveredBounds.length > 0 && candidate.areaFraction <= 0.2) {
1787
+ const candidateArea = boundsArea(bounds);
1788
+ let insideArea = 0;
1789
+ for (const covered of coveredBounds) {
1790
+ const intersection = [
1791
+ Math.max(bounds[0], covered[0]),
1792
+ Math.max(bounds[1], covered[1]),
1793
+ Math.min(bounds[2], covered[2]),
1794
+ Math.min(bounds[3], covered[3])
1795
+ ];
1796
+ insideArea += boundsArea(intersection);
1797
+ }
1798
+ if (candidateArea > 0 && insideArea >= 0.8 * candidateArea) return;
1799
+ }
1800
+ if (coveredBounds.length > 0 && !candidate.clipped) {
1801
+ invertScanRect(self, candidate.rect, matrix);
1802
+ coveredBounds.push(bounds);
1803
+ return;
1804
+ }
1805
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN && candidate.inked) {
1806
+ invertScanRect(self, candidate.rect, matrix);
1807
+ coveredBounds.push(bounds);
1808
+ flushPendingTiles(self);
1809
+ return;
1810
+ }
1811
+ if (candidate.clipped) {
1812
+ paintSerial++;
1813
+ return;
1814
+ }
1815
+ if (candidate.areaFraction >= SCAN_COVERAGE_MIN) {
1816
+ for (let i = pendingTiles.length - 1; i >= 0; i--) {
1817
+ const pending = pendingTiles[i];
1818
+ const intersection = [
1819
+ Math.max(bounds[0], pending.bounds[0]),
1820
+ Math.max(bounds[1], pending.bounds[1]),
1821
+ Math.min(bounds[2], pending.bounds[2]),
1822
+ Math.min(bounds[3], pending.bounds[3])
1823
+ ];
1824
+ const pendingArea = boundsArea(pending.bounds);
1825
+ if (pendingArea > 0 && boundsArea(intersection) >= 0.8 * pendingArea) {
1826
+ pendingTiles.splice(i, 1);
1827
+ }
1828
+ }
1829
+ pendingTileArea = pendingTiles.reduce((sum, t) => sum + t.area, 0);
1830
+ pendingHasInk = pendingTiles.some((t) => t.inked);
1831
+ }
1832
+ let contributedArea = candidate.areaFraction;
1833
+ if (isAxisAligned(matrix) && pageArea) {
1834
+ let pieces = [bounds];
1835
+ for (const pending of pendingTiles) {
1836
+ pieces = subtractBounds(pieces, pending.bounds);
1837
+ }
1838
+ contributedArea = pieces.reduce((sum, piece) => sum + boundsArea(piece), 0) / pageArea;
1839
+ } else if (overlapsAny(
1840
+ bounds,
1841
+ pendingTiles.map((t) => t.bounds)
1842
+ )) {
1843
+ return;
1844
+ }
1845
+ if (pendingTiles.length === 0) pendingBaselineSerial = paintSerial;
1846
+ pendingTiles.push({
1847
+ rect: candidate.rect,
1848
+ matrix,
1849
+ bounds,
1850
+ area: contributedArea,
1851
+ inked: candidate.inked,
1852
+ serial: paintSerial
1853
+ });
1854
+ pendingTileArea += contributedArea;
1855
+ pendingHasInk ||= candidate.inked;
1856
+ if (!pendingTriggerReady()) return;
1857
+ flushPendingTiles(self);
1858
+ };
1859
+ const withImageHandling = (original) => function(...args) {
1478
1860
  if (mappedWhiteLuma !== null && mappedBlackLuma !== null && this.globalCompositeOperation === "destination-in" && typeof this.filter === "string" && this.filter.includes("luminosity")) {
1479
1861
  const source = args[0];
1480
1862
  const sourcePainted = (typeof HTMLCanvasElement !== "undefined" && source instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) && source.getContext("2d")?.[RECOLOR_PAINTED] === true;
1481
1863
  const corrected = sourcePainted ? correctLuminosityMask(source, mappedWhiteLuma, mappedBlackLuma) : null;
1864
+ paintSerial++;
1482
1865
  if (corrected) {
1483
1866
  const next = [corrected, ...args.slice(1)];
1484
1867
  return original.apply(this, next);
1485
1868
  }
1869
+ return original.apply(this, args);
1486
1870
  }
1487
- return original.apply(this, args);
1871
+ const candidate = scanCandidate(this, args);
1872
+ if (!candidate) paintSerial++;
1873
+ const result = original.apply(this, args);
1874
+ if (candidate) handleScanCandidate(this, candidate);
1875
+ return result;
1488
1876
  };
1489
1877
  const withMappedStyle = (original, styleProp) => function(...args) {
1490
1878
  const style = this[styleProp];
1879
+ const invisible = this.globalCompositeOperation === "source-over" && (this.globalAlpha === 0 || style === "transparent" || typeof style === "string" && style.startsWith("rgba(") && style.endsWith(", 0)"));
1880
+ if (!invisible) paintSerial++;
1491
1881
  if (typeof style === "string") {
1492
1882
  const mapped = map(style);
1493
1883
  if (mapped !== style) {
@@ -1520,14 +1910,41 @@ function applyContextRecolor(ctx, map) {
1520
1910
  record[name] = withMappedStyle(target[name], "strokeStyle");
1521
1911
  for (const name of GRADIENT_METHODS)
1522
1912
  record[name] = withMappedStops(target[name]);
1523
- record.drawImage = withMaskCorrection(target.drawImage);
1913
+ record.drawImage = withImageHandling(target.drawImage);
1914
+ const pristinePutImageData = target.putImageData;
1915
+ record.putImageData = function(...args) {
1916
+ paintSerial++;
1917
+ return pristinePutImageData.apply(this, args);
1918
+ };
1919
+ const pristineClearRect = target.clearRect;
1920
+ record.clearRect = function(...args) {
1921
+ paintSerial++;
1922
+ return pristineClearRect.apply(this, args);
1923
+ };
1924
+ record.save = function() {
1925
+ clipFlags.push(clipFlags[clipFlags.length - 1]);
1926
+ return pristineSave.call(this);
1927
+ };
1928
+ record.restore = function() {
1929
+ if (clipFlags.length > 1) clipFlags.pop();
1930
+ return pristineRestore.call(this);
1931
+ };
1932
+ record.clip = function(...args) {
1933
+ clipFlags[clipFlags.length - 1] = true;
1934
+ return pristineClip.apply(this, args);
1935
+ };
1524
1936
  const cleanup = () => {
1525
1937
  if (target[RECOLOR_CLEANUP] !== cleanup) return;
1526
1938
  for (const name of [
1527
1939
  ...FILL_METHODS,
1528
1940
  ...STROKE_METHODS,
1529
1941
  ...GRADIENT_METHODS,
1530
- "drawImage"
1942
+ "drawImage",
1943
+ "putImageData",
1944
+ "clearRect",
1945
+ "save",
1946
+ "restore",
1947
+ "clip"
1531
1948
  ])
1532
1949
  delete record[name];
1533
1950
  delete target[RECOLOR_CLEANUP];
@@ -1792,7 +2209,9 @@ var useCanvasLayer = ({ background }) => {
1792
2209
  };
1793
2210
  let restoreRecolor = null;
1794
2211
  if (recolor) {
1795
- restoreRecolor = applyContextRecolor(renderCtx, recolor);
2212
+ restoreRecolor = applyContextRecolor(renderCtx, recolor, {
2213
+ pageArea: viewport.width * viewport.height
2214
+ });
1796
2215
  } else {
1797
2216
  removeContextRecolor(renderCtx);
1798
2217
  }
@@ -2165,7 +2584,10 @@ var useDetailCanvasLayer = ({
2165
2584
  const detailViewport = pdfPageProxy.getViewport({
2166
2585
  scale: effectiveScale
2167
2586
  });
2168
- if (recolor) applyContextRecolor(bufferCtx, recolor);
2587
+ if (recolor)
2588
+ applyContextRecolor(bufferCtx, recolor, {
2589
+ pageArea: detailViewport.width * detailViewport.height
2590
+ });
2169
2591
  const releaseBuffer = () => {
2170
2592
  buffer.width = 0;
2171
2593
  buffer.height = 0;
@@ -6671,7 +7093,9 @@ var useThumbnail = (pageNumber, config = {}) => {
6671
7093
  if (!context) return;
6672
7094
  let restoreRecolor = null;
6673
7095
  if (recolor) {
6674
- restoreRecolor = applyContextRecolor(context, recolor);
7096
+ restoreRecolor = applyContextRecolor(context, recolor, {
7097
+ pageArea: scaledViewport.width * scaledViewport.height
7098
+ });
6675
7099
  } else {
6676
7100
  removeContextRecolor(context);
6677
7101
  }
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.0",
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
  }