▼class CanvasGraphics[3875 lines]
494class CanvasGraphics {
495 // Only used in rescaleAndStroke. The goal is to avoid
496 // creating a new DOMMatrix object each time we need it.
497 static #SCALE_MATRIX = null;
498
499 // Knockout group support fields.
500 #knockoutGroupLevel = 0;
501
502 #knockoutElementDepth = 0;
503
504 #knockoutTempCanvasEntry = null;
505
506 #knockoutSavedCtx = null;
507
508 #knockoutSavedSMaskCtx = null;
509
510 // Parent ctx globalCompositeOperation (GCO) at element start. Restored on
511 // tempCtx before the post-element copyCtxState so the saved ctx keeps its
512 // blend mode.
513 #knockoutSavedGCO = null;
514
515 #knockoutElementAlpha = 1;
516
517 /**
518 * Lazy alpha-scaling filter cache, populated on the first translucent
519 * knockout element. One of:
520 * - `Map<alpha, url>` - when `ctx.filter` is supported; one SVG filter
521 * per quantised alpha_s value (cache bounded by 8-bit alpha precision).
522 * - `"none"` - no DOM available; the JS pixel-loop fallback handles
523 * scaling instead.
524 * Stays `undefined` until the first translucent element forces a resolve.
525 * @type {Map<number, string> | "none" | undefined}
526 */
527 #knockoutFilterCache;
528
529 // Snapshot of #groupStackMeta.at(-1) at element-begin so the right backdrop
530 // is used even if nested groups push/pop during the element's lifetime.
531 #knockoutElementGroupMeta = null;
532
533 // Per-group metadata, aligned with `groupStack`. `null` for the no-canvas
534 // fast path. Otherwise: `backdropCtx` (parent ctx for non-isolated KO,
535 // read directly since it's frozen), `hasInnerBackdrop` (non-isolated
536 // non-KO subgroup inside a KO parent), `savedKnockoutLevel` (level to
537 // restore on exit), pixel offsets, and pooled scratch entries.
538 #groupStackMeta = [];
539
540 constructor(
541 canvasCtx,
542 commonObjs,
543 objs,
544 canvasFactory,
545 filterFactory,
546 { optionalContentConfig, markedContentStack = null },
547 annotationCanvasMap,
548 pageColors,
549 dependencyTracker,
550 imagesTracker
551 ) {
552 this.ctx = canvasCtx;
553 this.current = new CanvasExtraState(
554 this.ctx.canvas.width,
555 this.ctx.canvas.height
556 );
557 this.stateStack = [];
558 this.pendingClip = null;
559 this.pendingEOFill = false;
560 this.commonObjs = commonObjs;
561 this.objs = objs;
562 this.canvasFactory = canvasFactory;
563 this.filterFactory = filterFactory;
564 this.groupStack = [];
565 // Patterns are painted relative to the initial page/form transform, see
566 // PDF spec 8.7.2 NOTE 1.
567 this.baseTransform = null;
568 this.baseTransformStack = [];
569 this.groupLevel = 0;
570 this.smaskStack = [];
571 this.tempSMask = null;
572 this.smaskGroupCanvases = [];
573 this.smaskPreparedEntry = null;
574 this.smaskPreparedFor = null;
575 this.smaskPreparedOffsetX = 0;
576 this.smaskPreparedOffsetY = 0;
577 // For mask-size prebakes with non-zero OOB alpha, the constant
578 // alpha applied to OOB pixels (dirty box outside the mask canvas)
579 // at compose time. Null when no compose-time OOB work is needed:
580 // - layer-size prebake bakes OOB inline; or
581 // - OOB alpha is 0 and destination-in's transparent source
582 // samples clear OOB layer pixels for free.
583 // Compose-time behavior splits on this:
584 // null -> clip = full dirty box; OOB cleared or baked.
585 // 255 -> clip excludes OOB; OOB survives unchanged.
586 // intermediate -> clip excludes OOB, then a fade pass applies
587 // this constant alpha.
588 this.smaskPreparedOOBAlpha = null;
589 this.suspendedCtx = null;
590 this.contentVisible = true;
591 this.markedContentStack = markedContentStack || [];
592 this.optionalContentConfig = optionalContentConfig;
593 this.cachedPatterns = new Map();
594 this.annotationCanvasMap = annotationCanvasMap;
595 this.viewportScale = 1;
596 this.outputScaleX = 1;
597 this.outputScaleY = 1;
598 this.pageColors = pageColors;
599
600 this._cachedScaleForStroking = [-1, 0];
601 this._cachedGetSinglePixelWidth = null;
602 this._cachedBitmapsMap = new Map();
603
604 this.dependencyTracker = dependencyTracker ?? null;
605 this.imagesTracker = imagesTracker ?? null;
606 }
607
608 getObject(opIdx, data, fallback = null) {
609 if (typeof data === "string") {
610 this.dependencyTracker?.recordNamedDependency(opIdx, data);
611 return data.startsWith("g_")
612 ? this.commonObjs.get(data)
613 : this.objs.get(data);
614 }
615 return fallback;
616 }
617
618 beginDrawing({
619 transform,
620 viewport,
621 transparency = false,
622 background = null,
623 }) {
624 // For pdfs that use blend modes we have to clear the canvas else certain
625 // blend modes can look wrong since we'd be blending with a white
626 // backdrop. The problem with a transparent backdrop though is we then
627 // don't get sub pixel anti aliasing on text, creating temporary
628 // transparent canvas when we have blend modes.
629 const width = this.ctx.canvas.width;
630 const height = this.ctx.canvas.height;
631
632 const savedFillStyle = this.ctx.fillStyle;
633 this.ctx.fillStyle = background || "#ffffff";
634 this.ctx.fillRect(0, 0, width, height);
635 this.ctx.fillStyle = savedFillStyle;
636
637 if (transparency) {
638 const transparentCanvas = (this.transparentCanvasEntry =
639 this.canvasFactory.create(width, height));
640 this.compositeCtx = this.ctx;
641 ({ canvas: this.transparentCanvas, context: this.ctx } =
642 transparentCanvas);
643 this.ctx.save();
644 // The transform can be applied before rendering, transferring it to
645 // the new canvas.
646 this.ctx.transform(...getCurrentTransform(this.compositeCtx));
647 }
648
649 this.ctx.save();
650 resetCtxToDefault(this.ctx);
651 if (transform) {
652 this.ctx.transform(...transform);
653 this.outputScaleX = transform[0];
654 this.outputScaleY = transform[3];
655 }
656 this.ctx.transform(...viewport.transform);
657 this.viewportScale = viewport.scale;
658
659 this.baseTransform = getCurrentTransform(this.ctx);
660 }
661
662 executeOperatorList(
663 operatorList,
664 executionStartIdx,
665 continueCallback,
666 stepper,
667 operationsFilter
668 ) {
669 const argsArray = operatorList.argsArray;
670 const fnArray = operatorList.fnArray;
671 let i = executionStartIdx || 0;
672 const argsArrayLen = argsArray.length;
673
674 // Sometimes the OperatorList to execute is empty.
675 if (argsArrayLen === i) {
676 return i;
677 }
678
679 const chunkOperations =
680 argsArrayLen - i > EXECUTION_STEPS &&
681 typeof continueCallback === "function";
682 const endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;
683 let steps = 0;
684
685 const commonObjs = this.commonObjs;
686 const objs = this.objs;
687 let fnId, fnArgs;
688
689 while (true) {
690 if (stepper !== undefined) {
691 if (i === stepper.nextBreakPoint) {
692 stepper.breakIt(i, continueCallback);
693 return i;
694 }
695 if (stepper.shouldSkip(i)) {
696 if (++i === argsArrayLen) {
697 return i;
698 }
699 continue;
700 }
701 }
702
703 if (!operationsFilter || operationsFilter(i)) {
704 fnId = fnArray[i];
705 // TODO: There is a `undefined` coming from somewhere.
706 fnArgs = argsArray[i] ?? null;
707
708 if (fnId !== OPS.dependency) {
709 if (fnArgs === null) {
710 this[fnId](i);
711 } else {
712 this[fnId](i, ...fnArgs);
713 }
714 } else {
715 for (const depObjId of fnArgs) {
716 this.dependencyTracker?.recordNamedData(depObjId, i);
717 const objsPool = depObjId.startsWith("g_") ? commonObjs : objs;
718
719 // If the promise isn't resolved yet, add the continueCallback
720 // to the promise and bail out.
721 if (!objsPool.has(depObjId)) {
722 objsPool.get(depObjId, continueCallback);
723 return i;
724 }
725 }
726 }
727 }
728
729 i++;
730
731 // If the entire operatorList was executed, stop as were done.
732 if (i === argsArrayLen) {
733 return i;
734 }
735
736 // If the execution took longer then a certain amount of time and
737 // `continueCallback` is specified, interrupt the execution.
738 if (chunkOperations && ++steps > EXECUTION_STEPS) {
739 if (Date.now() > endTime) {
740 continueCallback();
741 return i;
742 }
743 steps = 0;
744 }
745
746 // If the operatorList isn't executed completely yet OR the execution
747 // time was short enough, do another execution round.
748 }
749 }
750
751 #restoreInitialState() {
752 // Finishing all opened operations such as SMask group painting.
753 while (this.stateStack.length || this.inSMaskMode) {
754 this.restore();
755 }
756
757 this.current.activeSMask = null;
758 this.ctx.restore();
759
760 if (this.transparentCanvas) {
761 this.ctx = this.compositeCtx;
762 this.ctx.save();
763 this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Avoid apply transform twice
764 this.ctx.drawImage(this.transparentCanvas, 0, 0);
765 this.ctx.restore();
766 this.canvasFactory.destroy(this.transparentCanvasEntry);
767 this.transparentCanvas = null;
768 this.transparentCanvasEntry = null;
769 }
770 }
771
772 endDrawing() {
773 this.#restoreInitialState();
774
775 // Destroy all smask group canvases now that rendering is complete.
776 // These cannot be destroyed eagerly because activeSMask is part of
777 // CanvasExtraState and is shared (via Object.create prototype chain) across
778 // save/restore state copies.
779 for (const canvas of this.smaskGroupCanvases) {
780 this.canvasFactory.destroy(canvas);
781 }
782 this.smaskGroupCanvases.length = 0;
783 this._clearPreparedSMask();
784 this.tempSMask = null;
785 this.smaskStack.length = 0;
786
787 // Drop knockout state in case rendering was cancelled mid-group. Pooled
788 // temp/backdrop entries are owned by the meta and freed there; the
789 // active-element fields just alias into the meta, so only clear them.
790 for (const meta of this.#groupStackMeta) {
791 this.#destroyKnockoutPools(meta);
792 }
793 this.#groupStackMeta.length = 0;
794 this.#knockoutTempCanvasEntry = null;
795 this.#knockoutSavedCtx = null;
796 this.#knockoutSavedSMaskCtx = null;
797 this.#knockoutSavedGCO = null;
798 this.#knockoutElementAlpha = 1;
799 this.#knockoutElementGroupMeta = null;
800 this.#knockoutElementDepth = 0;
801 this.#knockoutGroupLevel = 0;
802
803 this.cachedPatterns.clear();
804
805 for (const cache of this._cachedBitmapsMap.values()) {
806 for (const canvas of cache.values()) {
807 if (
808 typeof HTMLCanvasElement !== "undefined" &&
809 canvas instanceof HTMLCanvasElement
810 ) {
811 canvas.width = canvas.height = 0;
812 }
813 }
814 cache.clear();
815 }
816 this._cachedBitmapsMap.clear();
817 this.#drawFilter();
818 }
819
820 #drawFilter() {
821 if (this.pageColors) {
822 const hcmFilterId = this.filterFactory.addHCMFilter(
823 this.pageColors.foreground,
824 this.pageColors.background
825 );
826 if (hcmFilterId !== "none") {
827 const savedFilter = this.ctx.filter;
828 this.ctx.filter = hcmFilterId;
829 this.ctx.drawImage(this.ctx.canvas, 0, 0);
830 this.ctx.filter = savedFilter;
831 }
832 }
833 }
834
835 _scaleImage(img, inverseTransform) {
836 // Vertical or horizontal scaling shall not be more than 2 to not lose the
837 // pixels during drawImage operation, painting on the temporary canvas(es)
838 // that are twice smaller in size.
839
840 // displayWidth and displayHeight are used for VideoFrame.
841 const width = img.width ?? img.displayWidth;
842 const height = img.height ?? img.displayHeight;
843 const widthScale = Math.max(
844 Math.hypot(inverseTransform[0], inverseTransform[1]),
845 1
846 );
847 const heightScale = Math.max(
848 Math.hypot(inverseTransform[2], inverseTransform[3]),
849 1
850 );
851
852 // Pre-compute each step's output dimensions.
853 const scaleSteps = [];
854 let ws = widthScale,
855 hs = heightScale,
856 pw = width,
857 ph = height;
858 while ((ws > 2 && pw > 1) || (hs > 2 && ph > 1)) {
859 let nw = pw,
860 nh = ph;
861 if (ws > 2 && pw > 1) {
862 nw = Math.ceil(pw / 2);
863 ws /= pw / nw;
864 }
865 if (hs > 2 && ph > 1) {
866 nh = Math.ceil(ph / 2);
867 hs /= ph / nh;
868 }
869 scaleSteps.push({ newWidth: nw, newHeight: nh });
870 pw = nw;
871 ph = nh;
872 }
873
874 if (scaleSteps.length === 0) {
875 return { img, paintWidth: width, paintHeight: height, tmpCanvas: null };
876 }
877
878 if (scaleSteps.length === 1) {
879 const { newWidth, newHeight } = scaleSteps[0];
880 const tmpCanvas = this.canvasFactory.create(newWidth, newHeight);
881 tmpCanvas.context.drawImage(
882 img,
883 0,
884 0,
885 width,
886 height,
887 0,
888 0,
889 newWidth,
890 newHeight
891 );
892 return {
893 img: tmpCanvas.canvas,
894 paintWidth: newWidth,
895 paintHeight: newHeight,
896 tmpCanvas,
897 };
898 }
899
900 // More than 2 steps: ping-pong between two reused canvas entries.
901 // canvasFactory.reset() resizes (and implicitly clears) a canvas without
902 // creating a new JS object or calling getContext() again.
903 let readEntry = this.canvasFactory.create(1, 1);
904 let writeEntry = this.canvasFactory.create(1, 1);
905 let paintWidth = width,
906 paintHeight = height;
907 let source = img;
908
909 for (const { newWidth, newHeight } of scaleSteps) {
910 this.canvasFactory.reset(writeEntry, newWidth, newHeight);
911 writeEntry.context.drawImage(
912 source,
913 0,
914 0,
915 paintWidth,
916 paintHeight,
917 0,
918 0,
919 newWidth,
920 newHeight
921 );
922 [readEntry, writeEntry] = [writeEntry, readEntry];
923 source = readEntry.canvas;
924 paintWidth = newWidth;
925 paintHeight = newHeight;
926 }
927
928 // writeEntry is now the stale buffer; destroy it.
929 this.canvasFactory.destroy(writeEntry);
930 return {
931 img: readEntry.canvas,
932 paintWidth,
933 paintHeight,
934 tmpCanvas: readEntry,
935 };
936 }
937
938 _createMaskCanvas(opIdx, img) {
939 const ctx = this.ctx;
940 const { width, height } = img;
941 const fillColor = this.current.fillColor;
942 const isPatternFill = this.current.patternFill;
943 const currentTransform = getCurrentTransform(ctx);
944
945 let cache, cacheKey, scaled, maskCanvas;
946 if ((img.bitmap || img.data) && img.count > 1) {
947 const mainKey = img.bitmap || img.data.buffer;
948 // We're reusing the same image several times, so we can cache it.
949 // In case we've a pattern fill we just keep the scaled version of
950 // the image.
951 // Only the scaling part matters, the translation part is just used
952 // to compute offsets (but not when filling patterns see #15573).
953 // TODO: handle the case of a pattern fill if it's possible.
954 cacheKey = JSON.stringify(
955 isPatternFill
956 ? currentTransform
957 : [currentTransform.slice(0, 4), fillColor]
958 );
959
960 cache = this._cachedBitmapsMap.getOrInsertComputed(mainKey, makeMap);
961 const cachedImage = cache.get(cacheKey);
962 if (cachedImage && !isPatternFill) {
963 const offsetX = Math.round(
964 Math.min(currentTransform[0], currentTransform[2]) +
965 currentTransform[4]
966 );
967 const offsetY = Math.round(
968 Math.min(currentTransform[1], currentTransform[3]) +
969 currentTransform[5]
970 );
971
972 this.dependencyTracker?.recordDependencies(
973 opIdx,
974 Dependencies.transformAndFill
975 );
976 return {
977 canvas: cachedImage,
978 offsetX,
979 offsetY,
980 };
981 }
982 scaled = cachedImage;
983 }
984
985 if (!scaled) {
986 maskCanvas = this.canvasFactory.create(width, height);
987 putBinaryImageMask(maskCanvas.context, img);
988 }
989
990 // Create the mask canvas at the size it will be drawn at and also set
991 // its transform to match the current transform so if there are any
992 // patterns applied they will be applied relative to the correct
993 // transform.
994
995 let maskToCanvas = Util.transform(currentTransform, [
996 1 / width,
997 0,
998 0,
999 -1 / height,
1000 0,
1001 0,
1002 ]);
1003 maskToCanvas = Util.transform(maskToCanvas, [1, 0, 0, 1, 0, -height]);
1004 const minMax = F32_BBOX_INIT.slice();
1005 Util.axialAlignedBoundingBox([0, 0, width, height], maskToCanvas, minMax);
1006 const [minX, minY, maxX, maxY] = minMax;
1007 const drawnWidth = Math.round(maxX - minX) || 1;
1008 const drawnHeight = Math.round(maxY - minY) || 1;
1009 const fillCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
1010 const fillCtx = fillCanvas.context;
1011
1012 // The offset will be the top-left coordinate mask.
1013 // If objToCanvas is [a,b,c,d,e,f] then:
1014 // - offsetX = min(a, c) + e
1015 // - offsetY = min(b, d) + f
1016 const offsetX = minX;
1017 const offsetY = minY;
1018 fillCtx.translate(-offsetX, -offsetY);
1019 fillCtx.transform(...maskToCanvas);
1020
1021 let scaledEntry = null;
1022 if (!scaled) {
1023 // Pre-scale if needed to improve image smoothing.
1024 const scaleResult = this._scaleImage(
1025 maskCanvas.canvas,
1026 getCurrentTransformInverse(fillCtx)
1027 );
1028 scaled = scaleResult.img;
1029 scaledEntry = scaleResult.tmpCanvas;
1030 if (scaled !== maskCanvas.canvas) {
1031 // _scaleImage created a new canvas; maskCanvas is no longer needed.
1032 this.canvasFactory.destroy(maskCanvas);
1033 maskCanvas = null;
1034 }
1035 if (cache && isPatternFill) {
1036 cache.set(cacheKey, scaled);
1037 scaledEntry = null; // bitmap cache owns the canvas now
1038 maskCanvas = null; // bitmap cache may own maskCanvas.canvas (= scaled)
1039 }
1040 }
1041
1042 fillCtx.imageSmoothingEnabled = getImageSmoothingEnabled(
1043 getCurrentTransform(fillCtx),
1044 img.interpolate
1045 );
1046
1047 drawImageAtIntegerCoords(
1048 fillCtx,
1049 scaled,
1050 0,
1051 0,
1052 scaled.width,
1053 scaled.height,
1054 0,
1055 0,
1056 width,
1057 height
1058 );
1059 if (scaledEntry) {
1060 this.canvasFactory.destroy(scaledEntry);
1061 }
1062 if (maskCanvas) {
1063 // scaled === maskCanvas.canvas and not owned by the bitmap cache.
1064 this.canvasFactory.destroy(maskCanvas);
1065 }
1066 fillCtx.globalCompositeOperation = "source-in";
1067
1068 const inverse = Util.transform(getCurrentTransformInverse(fillCtx), [
1069 1,
1070 0,
1071 0,
1072 1,
1073 -offsetX,
1074 -offsetY,
1075 ]);
1076 fillCtx.fillStyle = isPatternFill
1077 ? fillColor.getPattern(ctx, this, inverse, PathType.FILL, opIdx)
1078 : fillColor;
1079
1080 fillCtx.fillRect(0, 0, width, height);
1081
1082 if (cache && !isPatternFill) {
1083 // The fill canvas is put in the cache associated to the mask image
1084 // so it mustn't be used again.
1085 cache.set(cacheKey, fillCanvas.canvas);
1086 }
1087
1088 this.dependencyTracker?.recordDependencies(
1089 opIdx,
1090 Dependencies.transformAndFill
1091 );
1092
1093 // Round the offsets to avoid drawing fractional pixels.
1094 return {
1095 canvas: fillCanvas.canvas,
1096 // canvasEntry is null when the bitmap cache owns the canvas.
1097 canvasEntry: cache && !isPatternFill ? null : fillCanvas,
1098 offsetX: Math.round(offsetX),
1099 offsetY: Math.round(offsetY),
1100 };
1101 }
1102
1103 // Graphics state
1104 setLineWidth(opIdx, width) {
1105 this.dependencyTracker?.recordSimpleData("lineWidth", opIdx);
1106 if (width !== this.current.lineWidth) {
1107 this._cachedScaleForStroking[0] = -1;
1108 }
1109 this.current.lineWidth = width;
1110 this.ctx.lineWidth = width;
1111 }
1112
1113 setLineCap(opIdx, style) {
1114 this.dependencyTracker?.recordSimpleData("lineCap", opIdx);
1115 this.ctx.lineCap = LINE_CAP_STYLES[style];
1116 }
1117
1118 setLineJoin(opIdx, style) {
1119 this.dependencyTracker?.recordSimpleData("lineJoin", opIdx);
1120 this.ctx.lineJoin = LINE_JOIN_STYLES[style];
1121 }
1122
1123 setMiterLimit(opIdx, limit) {
1124 this.dependencyTracker?.recordSimpleData("miterLimit", opIdx);
1125 this.ctx.miterLimit = limit;
1126 }
1127
1128 setDash(opIdx, dashArray, dashPhase) {
1129 this.dependencyTracker?.recordSimpleData("dash", opIdx);
1130 const ctx = this.ctx;
1131 if (ctx.setLineDash !== undefined) {
1132 ctx.setLineDash(dashArray);
1133 ctx.lineDashOffset = dashPhase;
1134 }
1135 }
1136
1137 setRenderingIntent(opIdx, intent) {
1138 // This operation is ignored since we haven't found a use case for it yet.
1139 }
1140
1141 setFlatness(opIdx, flatness) {
1142 // This operation is ignored since we haven't found a use case for it yet.
1143 }
1144
1145 setGState(opIdx, states) {
1146 for (const [key, value] of states) {
1147 switch (key) {
1148 case "LW":
1149 this.setLineWidth(opIdx, value);
1150 break;
1151 case "LC":
1152 this.setLineCap(opIdx, value);
1153 break;
1154 case "LJ":
1155 this.setLineJoin(opIdx, value);
1156 break;
1157 case "ML":
1158 this.setMiterLimit(opIdx, value);
1159 break;
1160 case "D":
1161 this.setDash(opIdx, value[0], value[1]);
1162 break;
1163 case "RI":
1164 this.setRenderingIntent(opIdx, value);
1165 break;
1166 case "FL":
1167 this.setFlatness(opIdx, value);
1168 break;
1169 case "Font":
1170 this.setFont(opIdx, value[0], value[1]);
1171 break;
1172 case "CA":
1173 this.dependencyTracker?.recordSimpleData("strokeAlpha", opIdx);
1174 this.current.strokeAlpha = value;
1175 break;
1176 case "ca":
1177 this.dependencyTracker?.recordSimpleData("fillAlpha", opIdx);
1178 this.ctx.globalAlpha = this.current.fillAlpha = value;
1179 break;
1180 case "BM":
1181 this.dependencyTracker?.recordSimpleData(
1182 "globalCompositeOperation",
1183 opIdx
1184 );
1185 this.ctx.globalCompositeOperation = value;
1186 break;
1187 case "SMask":
1188 this.dependencyTracker?.recordSimpleData("SMask", opIdx);
1189 this.current.activeSMask = value ? this.tempSMask : null;
1190 if (this.current.activeSMask) {
1191 // Save the current blend mode so that it can be applied when
1192 // compositing the SMask result back to the main canvas.
1193 this.current.activeSMask.blendMode =
1194 this.ctx.globalCompositeOperation;
1195 }
1196 this.tempSMask = null;
1197 this.checkSMaskState(opIdx);
1198 break;
1199 case "TR":
1200 this.dependencyTracker?.recordSimpleData("filter", opIdx);
1201 this.ctx.filter = this.current.transferMaps =
1202 this.filterFactory.addFilter(value);
1203 break;
1204 }
1205 }
1206 }
1207
1208 get inSMaskMode() {
1209 return !!this.suspendedCtx;
1210 }
1211
1212 _clearPreparedSMask() {
1213 if (this.smaskPreparedEntry) {
1214 this.canvasFactory.destroy(this.smaskPreparedEntry);
1215 this.smaskPreparedEntry = null;
1216 }
1217 this.smaskPreparedFor = null;
1218 this.smaskPreparedOffsetX = 0;
1219 this.smaskPreparedOffsetY = 0;
1220 this.smaskPreparedOOBAlpha = null;
1221 }
1222
1223 _ensurePreparedSMask(smask) {
1224 if (smask === this.smaskPreparedFor) {
1225 return;
1226 }
1227 this._clearPreparedSMask();
1228 this._prepareSMaskCanvas(smask);
1229 }
1230
1231 checkSMaskState(opIdx) {
1232 const inSMaskMode = this.inSMaskMode;
1233 if (this.current.activeSMask && !inSMaskMode) {
1234 this.beginSMaskMode(opIdx);
1235 } else if (!this.current.activeSMask && inSMaskMode) {
1236 this.endSMaskMode();
1237 } else if (this.current.activeSMask && inSMaskMode) {
1238 // The active SMask may have changed while SMask mode stayed active
1239 // (e.g. a direct SMask A->B replacement, or a restore() that surfaces
1240 // a different saved mask). _ensurePreparedSMask is a no-op when the
1241 // same mask object is re-encountered.
1242 this._ensurePreparedSMask(this.current.activeSMask);
1243 }
1244 }
1245
1246 _prepareSMaskCanvas(smask) {
1247 const { canvas: maskCanvas, subtype, backdrop, transferMap } = smask;
1248 const hasFilter =
1249 subtype === "Luminosity" || (subtype === "Alpha" && transferMap);
1250
1251 // Nothing to amortize unless we have a filter or a Luminosity
1252 // backdrop -- Alpha SMasks ignore /BC for the alpha output, and
1253 // unknown subtypes have no defined backdrop semantics. Record the
1254 // mask so checkSMaskState's identity check skips the rebuild path
1255 // on subsequent restore()/setGState() calls.
1256 if (!hasFilter && !(subtype === "Luminosity" && backdrop)) {
1257 this.smaskPreparedFor = smask;
1258 return;
1259 }
1260
1261 // Constant alpha OOB pixels receive after the spec backdrop+filter
1262 // chain (see smaskPreparedOOBAlpha field doc for the compose-time
1263 // table). /BC only feeds the alpha output for Luminosity (its
1264 // color enters the luminance computation). Alpha SMasks treat /BC
1265 // as a pure color-space backdrop and must not bake it into the
1266 // alpha output.
1267 let filteredOOBAlpha;
1268 if (subtype === "Luminosity" && backdrop) {
1269 // backdrop is "#RRGGBB" (see Evaluator#handleSMask).
1270 const [r, g, b] = getRGBA(backdrop);
1271 const inputAlpha = Math.round(0.3 * r + 0.59 * g + 0.11 * b);
1272 filteredOOBAlpha = transferMap?.[inputAlpha] ?? inputAlpha;
1273 } else {
1274 // Alpha, or Luminosity with no backdrop: OOB input is transparent,
1275 // and both filters map alpha=0 to alpha=0; only transferMap[0] can
1276 // produce a non-zero result.
1277 filteredOOBAlpha = transferMap?.[0] ?? 0;
1278 }
1279
1280 // Use a layer-size prebake when the layer is at most this many
1281 // times bigger than the mask: layer-size avoids compose-time OOB
1282 // work and hits the same-size drawImage GPU fast path, but the
1283 // alloc cost grows with the layer. The crossover is empirical;
1284 // tuned against the bug-2033095 corpus.
1285 const SMASK_LAYER_TO_MASK_AREA_RATIO = 4;
1286 const { width: layerW, height: layerH } = this.ctx.canvas;
1287 const maskArea = maskCanvas.width * maskCanvas.height;
1288 const useLayerSize =
1289 layerW * layerH < SMASK_LAYER_TO_MASK_AREA_RATIO * maskArea;
1290
1291 // Bundle the filter URL with the spec needed for the pixel-buffer
1292 // fallback (see _bakeSMaskCanvas). subtype + transferMap let the
1293 // fallback reproduce the SVG filter without an extra round-trip
1294 // through the filter factory.
1295 const filterSpec = hasFilter
1296 ? {
1297 url:
1298 subtype === "Alpha"
1299 ? this.filterFactory.addAlphaFilter(transferMap)
1300 : this.filterFactory.addLuminosityFilter(transferMap),
1301 subtype,
1302 transferMap,
1303 }
1304 : null;
1305
1306 // Alpha SMasks must not bake /BC into the prepared canvas (see
1307 // filteredOOBAlpha comment above).
1308 const bakedBackdrop = subtype === "Luminosity" ? backdrop : null;
1309
1310 let preparedEntry, offsetX, offsetY;
1311 if (useLayerSize) {
1312 preparedEntry = this._bakeSMaskCanvas(
1313 maskCanvas,
1314 smask.offsetX,
1315 smask.offsetY,
1316 layerW,
1317 layerH,
1318 bakedBackdrop,
1319 filterSpec
1320 );
1321 offsetX = 0;
1322 offsetY = 0;
1323 } else {
1324 preparedEntry = this._bakeSMaskCanvas(
1325 maskCanvas,
1326 0,
1327 0,
1328 maskCanvas.width,
1329 maskCanvas.height,
1330 bakedBackdrop,
1331 filterSpec
1332 );
1333 offsetX = smask.offsetX;
1334 offsetY = smask.offsetY;
1335 }
1336
1337 this.smaskPreparedEntry = preparedEntry;
1338 this.smaskPreparedFor = smask;
1339 this.smaskPreparedOffsetX = offsetX;
1340 this.smaskPreparedOffsetY = offsetY;
1341 // Only mask-size prebakes with non-zero OOB alpha need compose-time
1342 // OOB work (see field doc).
1343 this.smaskPreparedOOBAlpha =
1344 !useLayerSize && filteredOOBAlpha !== 0 ? filteredOOBAlpha : null;
1345 }
1346
1347 /**
1348 * Bake the mask plus optional backdrop into a (w x h) canvas with the
1349 * mask drawn at (drawX, drawY), then optionally pipe through the SVG
1350 * filter described by `filterSpec`. Returns the prepared canvas-
1351 * factory entry.
1352 *
1353 * The backdrop fill uses destination-atop so transparent / partial-
1354 * alpha pixels inside the mask see the backdrop *before* filtering
1355 * (per PDF spec). Filtering the raw mask would yield filter(0)
1356 * instead of filter(backdrop) -- wrong for "keep" Luminosity and for
1357 * Alpha masks whose transferMap[255] differs from transferMap[0].
1358 *
1359 * In the no-backdrop layer-size case the OOB region of srcEntry
1360 * stays transparent and the filter outputs filter(transparent) =
1361 * transferMap[0], matching the spec's transparent extension of the
1362 * mask group. No-backdrop mask-size prebakes have no OOB region;
1363 * destination-in handles OOB at compose time.
1364 *
1365 * Some browsers (e.g. older Safari) silently ignore SVG `url(#id)`
1366 * filters on a 2D canvas: the assignment is accepted but
1367 * `ctx.filter` reads back as "none" and `drawImage` produces an
1368 * unfiltered copy. We detect that and fall back to a pixel-buffer
1369 * loop that reproduces the SVG filter exactly (matrix luminance and
1370 * `feFuncA` transferMap, both with sRGB color-interpolation, i.e.
1371 * straight on gamma-encoded byte values).
1372 */
1373 _bakeSMaskCanvas(maskCanvas, drawX, drawY, w, h, backdrop, filterSpec) {
1374 if (!backdrop && !filterSpec) {
1375 // Caller (_prepareSMaskCanvas) gates on this; without either,
1376 // the prebake would just be a wasted copy of the mask.
1377 unreachable("_bakeSMaskCanvas with neither backdrop nor filter");
1378 }
1379 const srcEntry = this.canvasFactory.create(w, h);
1380 const sCtx = srcEntry.context;
1381 sCtx.drawImage(maskCanvas, drawX, drawY);
1382 if (backdrop) {
1383 sCtx.globalCompositeOperation = "destination-atop";
1384 sCtx.fillStyle = backdrop;
1385 sCtx.fillRect(0, 0, w, h);
1386 }
1387 if (!filterSpec) {
1388 return srcEntry;
1389 }
1390 const preparedEntry = this.canvasFactory.create(w, h);
1391 const pCtx = preparedEntry.context;
1392 // Post-assign "none"/"" means the URL was rejected (Firefox
1393 // normalizes accepted url(#id) to an absolute URL).
1394 pCtx.filter = filterSpec.url;
1395 const filterApplied =
1396 FeatureTest.isCanvasFilterSupported &&
1397 pCtx.filter !== "none" &&
1398 pCtx.filter !== "";
1399 pCtx.drawImage(srcEntry.canvas, 0, 0);
1400 if (FeatureTest.isCanvasFilterSupported) {
1401 pCtx.filter = "none";
1402 }
1403 if (!filterApplied) {
1404 const img = pCtx.getImageData(0, 0, w, h);
1405 const { data } = img;
1406 const { transferMap } = filterSpec;
1407 if (filterSpec.subtype === "Luminosity") {
1408 for (let i = 0, ii = data.length; i < ii; i += 4) {
1409 // Match #addLuminosityConversion: a' = 0.3*R + 0.59*G + 0.11*B,
1410 // RGB -> 0; then optional transferMap on alpha.
1411 const a =
1412 (0.3 * data[i] + 0.59 * data[i + 1] + 0.11 * data[i + 2] + 0.5) | 0;
1413 data[i] = data[i + 1] = data[i + 2] = 0;
1414 data[i + 3] = transferMap?.[a] ?? a;
1415 }
1416 } else {
1417 // Alpha: transferMap is guaranteed by _prepareSMaskCanvas's
1418 // hasFilter gate.
1419 for (let i = 3, ii = data.length; i < ii; i += 4) {
1420 data[i] = transferMap[data[i]];
1421 }
1422 }
1423 pCtx.putImageData(img, 0, 0);
1424 }
1425 this.canvasFactory.destroy(srcEntry);
1426 return preparedEntry;
1427 }
1428
1429 /**
1430 * Replaces the current drawing canvas with a temporary scratch canvas and
1431 * suspends the main context. Drawing operations on the scratch canvas are
1432 * composited back via `compose()`. The scratch canvas mirrors many operations
1433 * onto the suspended canvas to keep their graphics-state stacks in sync, so
1434 * that clipping paths and transformations remain correct when soft mask mode
1435 * ends.
1436 */
1437 beginSMaskMode(opIdx) {
1438 if (this.inSMaskMode) {
1439 throw new Error("beginSMaskMode called while already in smask mode");
1440 }
1441 const { width: drawnWidth, height: drawnHeight } = this.ctx.canvas;
1442 const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
1443 this.smaskScratchCanvas = scratchCanvas;
1444 this.suspendedCtx = this.ctx;
1445 const ctx = (this.ctx = scratchCanvas.context);
1446 ctx.setTransform(this.suspendedCtx.getTransform());
1447 copyCtxState(this.suspendedCtx, ctx);
1448 mirrorContextOperations(ctx, this.suspendedCtx);
1449
1450 this._ensurePreparedSMask(this.current.activeSMask);
1451
1452 this.setGState(opIdx, [["BM", "source-over"]]);
1453 }
1454
1455 endSMaskMode() {
1456 if (!this.inSMaskMode) {
1457 throw new Error("endSMaskMode called while not in smask mode");
1458 }
1459 // The soft mask is done, now restore the suspended canvas as the main
1460 // drawing canvas.
1461 this.ctx._removeMirroring();
1462 copyCtxState(this.ctx, this.suspendedCtx);
1463 this.ctx = this.suspendedCtx;
1464
1465 this.suspendedCtx = null;
1466 this.canvasFactory.destroy(this.smaskScratchCanvas);
1467 this.smaskScratchCanvas = null;
1468 this._clearPreparedSMask();
1469 }
1470
1471 #createKnockoutMaskCanvas(sourceCanvas, reuseEntry = null, alpha = 1) {
1472 const { width, height } = sourceCanvas;
1473 // reuseEntry is assumed to match sourceCanvas in size (all current call
1474 // sites guarantee this); the mask is rebuilt in-place.
1475 const maskEntry = reuseEntry ?? this.canvasFactory.create(width, height);
1476 const maskCtx = maskEntry.context;
1477 // Snap alpha_s to 8-bit precision: the painted alpha we're scaling is
1478 // already 8-bit, so any finer-grained alpha_s is indistinguishable. Caps
1479 // both the local Map and the filter-factory cache at <=256 entries
1480 // regardless of how many distinct gstate alpha values the PDF uses.
1481 alpha = Math.round(alpha * 255) / 255;
1482 const needsAlphaScaling = alpha < 1;
1483 if (needsAlphaScaling && this.#knockoutFilterCache === undefined) {
1484 // On Safari `ctx.filter` is settable but inert: the filter URL would
1485 // be stored without being applied, leaving the mask unscaled.
1486 // Force the JS fallback there.
1487 this.#knockoutFilterCache = FeatureTest.isCanvasFilterSupported
1488 ? new Map()
1489 : "none";
1490 }
1491 let knockoutFilter = "none";
1492 if (needsAlphaScaling && this.#knockoutFilterCache instanceof Map) {
1493 knockoutFilter = this.#knockoutFilterCache.getOrInsertComputed(
1494 alpha,
1495 () => this.filterFactory.addKnockoutFilter(alpha)
1496 );
1497 }
1498
1499 if (!needsAlphaScaling || knockoutFilter !== "none") {
1500 // Reused entries may carry stale pixels. Avoid the
1501 // globalCompositeOperation = "copy" + filter combo: that pair is
1502 // browser-divergent.
1503 if (reuseEntry) {
1504 maskCtx.save();
1505 maskCtx.setTransform(1, 0, 0, 1, 0, 0);
1506 maskCtx.clearRect(0, 0, width, height);
1507 maskCtx.restore();
1508 }
1509 maskCtx.filter = knockoutFilter;
1510 maskCtx.drawImage(sourceCanvas, 0, 0);
1511 maskCtx.filter = "none";
1512 return maskEntry;
1513 }
1514
1515 // No-DOM fallback (Node/embedded). Scale painted alpha back to shape
1516 // coverage; color channels are irrelevant for destination-out/in.
1517 const sourceData = sourceCanvas
1518 .getContext("2d", { willReadFrequently: true })
1519 .getImageData(0, 0, width, height);
1520 const maskData = maskCtx.createImageData(width, height);
1521 const sourcePixels = sourceData.data,
1522 maskPixels = maskData.data;
1523 const alphaScale = alpha > 0 ? 1 / alpha : 1e6;
1524 for (let i = 3, ii = sourcePixels.length; i < ii; i += 4) {
1525 maskPixels[i] = Math.min(Math.round(sourcePixels[i] * alphaScale), 255);
1526 }
1527 maskCtx.putImageData(maskData, 0, 0);
1528 return maskEntry;
1529 }
1530
1531 #getOrCreatePooledEntry(meta, key, width, height) {
1532 let entry = meta?.[key] ?? null;
1533 if (
1534 entry &&
1535 (entry.canvas.width !== width || entry.canvas.height !== height)
1536 ) {
1537 this.canvasFactory.destroy(entry);
1538 entry = null;
1539 }
1540 if (!entry) {
1541 entry = this.canvasFactory.create(width, height);
1542 if (meta) {
1543 meta[key] = entry;
1544 }
1545 return entry;
1546 }
1547 // Reused entry: clear any stale pixels before the caller refills it.
1548 const ctx = entry.context;
1549 ctx.save();
1550 ctx.setTransform(1, 0, 0, 1, 0, 0);
1551 ctx.clearRect(0, 0, width, height);
1552 ctx.restore();
1553 return entry;
1554 }
1555
1556 #compositeKnockoutSurface(destCtx, surfaceCanvas, options = {}) {
1557 const {
1558 // Backdrop canvas for non-isolated groups, or null for isolated.
1559 // Passed directly (no copy) since the parent canvas is frozen while
1560 // the group renders.
1561 backdropCanvas = null,
1562 // Transform for destCtx before the final draw. Identity is correct
1563 // when destCtx and surfaceCanvas share pixel coords (per-element
1564 // path); pass currentMtx for the endGroup subgroup-into-parent path.
1565 destTransform = [1, 0, 0, 1, 0, 0],
1566 // Pixel origin within backdropCanvas of the region that maps onto
1567 // surfaceCanvas. [0,0] when the backdrop is already pre-cropped;
1568 // pass the (possibly compounded) group offsets in endGroup.
1569 backdropOffset = [0, 0],
1570 // Pool entry to refill in place for the knockout mask. Caller owns
1571 // its lifetime when provided.
1572 reuseMaskEntry = null,
1573 // Group meta to pool the backdrop scratch on. Without it the scratch
1574 // is allocated and destroyed locally.
1575 poolMeta = null,
1576 // Per-element surfaces already have alpha/filter baked in (defaults
1577 // 1/"none"). Subgroup canvases don't, so endGroup passes the parent
1578 // values to apply only at the final draw.
1579 sourceAlpha = 1,
1580 sourceFilter = "none",
1581 knockoutAlpha = 1,
1582 } = options;
1583 const { width, height } = surfaceCanvas;
1584 const knockoutMaskEntry = this.#createKnockoutMaskCanvas(
1585 surfaceCanvas,
1586 reuseMaskEntry,
1587 knockoutAlpha
1588 );
1589 const sourceCompositeOperation = destCtx.globalCompositeOperation;
1590
1591 destCtx.save();
1592 destCtx.setTransform(...destTransform);
1593 destCtx.globalAlpha = 1;
1594 if (FeatureTest.isCanvasFilterSupported) {
1595 destCtx.filter = "none";
1596 }
1597
1598 // Erase prior group content wherever the new surface has any coverage.
1599 destCtx.globalCompositeOperation = "destination-out";
1600 destCtx.drawImage(knockoutMaskEntry.canvas, 0, 0);
1601
1602 if (backdropCanvas) {
1603 // Non-isolated: refill the just-erased footprint with the backdrop,
1604 // pre-clipped to the same shape mask so non-element pixels stay
1605 // transparent (otherwise sparse groups bleed the backdrop rect).
1606 const [bx, by] = backdropOffset;
1607 const backdropEntry = this.#getOrCreatePooledEntry(
1608 poolMeta,
1609 "knockoutBackdropEntry",
1610 width,
1611 height
1612 );
1613 const backdropCtx = backdropEntry.context;
1614 backdropCtx.drawImage(
1615 backdropCanvas,
1616 bx,
1617 by,
1618 width,
1619 height,
1620 0,
1621 0,
1622 width,
1623 height
1624 );
1625 backdropCtx.globalCompositeOperation = "destination-in";
1626 backdropCtx.drawImage(knockoutMaskEntry.canvas, 0, 0);
1627 // Reset the GCO so the pooled entry is in a known state for next use.
1628 backdropCtx.globalCompositeOperation = "source-over";
1629
1630 destCtx.globalCompositeOperation = "destination-over";
1631 destCtx.drawImage(backdropEntry.canvas, 0, 0);
1632 if (!poolMeta) {
1633 this.canvasFactory.destroy(backdropEntry);
1634 }
1635 }
1636 destCtx.globalCompositeOperation = sourceCompositeOperation;
1637 destCtx.globalAlpha = sourceAlpha;
1638 if (FeatureTest.isCanvasFilterSupported) {
1639 destCtx.filter = sourceFilter ?? "none";
1640 }
1641 destCtx.drawImage(surfaceCanvas, 0, 0);
1642
1643 destCtx.restore();
1644 if (!reuseMaskEntry) {
1645 this.canvasFactory.destroy(knockoutMaskEntry);
1646 }
1647 }
1648
1649 /**
1650 * Begin a knockout element. In a KO group each element composites against
1651 * the initial group backdrop (transparent if isolated, parent canvas if
1652 * not) rather than against the running group result. We render onto a temp
1653 * canvas; path/clip/transform ops are mirrored back to the group canvas so
1654 * its state stays in sync for the next element.
1655 *
1656 * @returns {boolean} true if a knockout element was started.
1657 */
1658 #beginKnockoutElement(alpha = 1) {
1659 if (
1660 this.#knockoutGroupLevel === 0 ||
1661 this.#knockoutElementDepth > 0 ||
1662 !this.contentVisible
1663 ) {
1664 return false;
1665 }
1666 this.#knockoutElementDepth++;
1667 this.#knockoutElementAlpha = alpha;
1668 const groupMeta = this.#groupStackMeta.at(-1);
1669 const { canvas } = this.ctx;
1670 const tempEntry = this.#getOrCreatePooledEntry(
1671 groupMeta,
1672 "knockoutTempEntry",
1673 canvas.width,
1674 canvas.height
1675 );
1676 this.#knockoutTempCanvasEntry = tempEntry;
1677 const tempCtx = tempEntry.context;
1678 // Bracket-save before installing mirroring so #endKnockoutElement can
1679 // restore() the pooled canvas to a clean clip+transform without
1680 // propagating that save through the mirror.
1681 tempCtx.save();
1682 tempCtx.setTransform(this.ctx.getTransform());
1683 copyCtxState(this.ctx, tempCtx);
1684 // Force source-over for the element raster: the parent's blend mode is
1685 // meant for the final composite back onto the group canvas (done by
1686 // #compositeKnockoutSurface), not for drawing onto a transparent temp
1687 // (e.g. multiply on alpha=0 zeros the element's colour). Stash the
1688 // parent GCO and re-apply it on tempCtx before the post-element
1689 // copyCtxState so the saved ctx keeps the parent blend mode.
1690 this.#knockoutSavedGCO = tempCtx.globalCompositeOperation;
1691 tempCtx.globalCompositeOperation = "source-over";
1692 mirrorContextOperations(tempCtx, this.ctx);
1693 this.#knockoutElementGroupMeta = groupMeta;
1694 this.#knockoutSavedCtx = this.ctx;
1695 this.#knockoutSavedSMaskCtx = this.suspendedCtx;
1696 this.ctx = tempCtx;
1697 if (this.inSMaskMode) {
1698 this.suspendedCtx = tempCtx;
1699 }
1700 return true;
1701 }
1702
1703 /**
1704 * End a knockout element started by `#beginKnockoutElement`. Composites
1705 * the rendered surface onto the group canvas with KO semantics: build a
1706 * shape mask from the element (painted alpha scaled back to geometric
1707 * coverage when alpha_s < 1), destination-out the group canvas over that
1708 * mask, restore the initial backdrop into the cleared footprint
1709 * (non-isolated only), then paint the element on top.
1710 *
1711 * @param {boolean} started - the value returned by `#beginKnockoutElement`.
1712 */
1713 #endKnockoutElement(started) {
1714 if (!started) {
1715 return;
1716 }
1717 const tempEntry = this.#knockoutTempCanvasEntry;
1718 const savedCtx = this.#knockoutSavedCtx;
1719 const savedSMaskCtx = this.#knockoutSavedSMaskCtx;
1720 const tempCtx = tempEntry.context;
1721 this.#knockoutTempCanvasEntry = null;
1722 this.#knockoutSavedCtx = null;
1723 this.#knockoutSavedSMaskCtx = null;
1724 if (
1725 this.inSMaskMode &&
1726 this.suspendedCtx === tempCtx &&
1727 this.ctx !== tempCtx
1728 ) {
1729 this.endSMaskMode();
1730 }
1731 if (this.inSMaskMode) {
1732 this.suspendedCtx = savedSMaskCtx;
1733 }
1734 this.ctx._removeMirroring();
1735 // Re-apply the parent GCO before copyCtxState writes it back to
1736 // savedCtx so #compositeKnockoutSurface sees the original blend mode.
1737 this.ctx.globalCompositeOperation = this.#knockoutSavedGCO;
1738 this.#knockoutSavedGCO = null;
1739 copyCtxState(this.ctx, savedCtx);
1740 this.ctx = savedCtx;
1741 const groupMeta = this.#knockoutElementGroupMeta;
1742 this.#knockoutElementGroupMeta = null;
1743 const knockoutAlpha = this.#knockoutElementAlpha;
1744 this.#knockoutElementAlpha = 1;
1745 try {
1746 this.#compositeKnockoutSurface(
1747 savedSMaskCtx ?? savedCtx,
1748 tempEntry.canvas,
1749 {
1750 backdropCanvas: groupMeta?.backdropCtx?.canvas ?? null,
1751 backdropOffset: groupMeta?.backdropCtx
1752 ? [groupMeta.offsetX, groupMeta.offsetY]
1753 : [0, 0],
1754 reuseMaskEntry: groupMeta?.knockoutMaskEntry ?? null,
1755 poolMeta: groupMeta,
1756 knockoutAlpha,
1757 }
1758 );
1759 } finally {
1760 // Pop the begin-element bracket save so the pooled canvas re-enters
1761 // with a clean clip+transform stack.
1762 tempCtx.restore();
1763 // Decrement only after the canvas is fully reset, so a re-entry from
1764 // a compositing callback sees depth>0 and bails out.
1765 this.#knockoutElementDepth--;
1766 // Defensive: groupMeta is non-null in practice for any active KO
1767 // element, but if it isn't we must release the unpooled entry.
1768 if (!groupMeta) {
1769 this.canvasFactory.destroy(tempEntry);
1770 }
1771 }
1772 }
1773
1774 compose(dirtyBox) {
1775 if (!this.current.activeSMask) {
1776 return;
1777 }
1778
1779 // Don't mutate the caller's box -- callers (e.g. consumePath) may
1780 // hold on to it.
1781 dirtyBox = dirtyBox
1782 ? [
1783 Math.floor(dirtyBox[0]),
1784 Math.floor(dirtyBox[1]),
1785 Math.ceil(dirtyBox[2]),
1786 Math.ceil(dirtyBox[3]),
1787 ]
1788 : [0, 0, this.ctx.canvas.width, this.ctx.canvas.height];
1789 const smask = this.current.activeSMask;
1790 const suspendedCtx = this.suspendedCtx;
1791 const applySMaskInPlace =
1792 this.#knockoutElementDepth > 0 && suspendedCtx === this.ctx;
1793
1794 this.composeSMask(
1795 applySMaskInPlace ? null : suspendedCtx,
1796 smask,
1797 this.ctx,
1798 dirtyBox
1799 );
1800 if (applySMaskInPlace) {
1801 return;
1802 }
1803
1804 // Clear the full scratch canvas, not just the dirty box. Pixels left
1805 // outside dirtyBox can leak into a later compose() whose destination-in
1806 // pass doesn't overwrite them, producing stale output -- this is what
1807 // breaks `firefox-issue17779-partial` (issue #21276).
1808 this.ctx.save();
1809 this.ctx.setTransform(1, 0, 0, 1, 0, 0);
1810 this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
1811 this.ctx.restore();
1812 }
1813
1814 composeSMask(ctx, smask, layerCtx, layerBox) {
1815 const layerOffsetX = layerBox[0];
1816 const layerOffsetY = layerBox[1];
1817 const layerWidth = layerBox[2] - layerOffsetX;
1818 const layerHeight = layerBox[3] - layerOffsetY;
1819 if (layerWidth === 0 || layerHeight === 0) {
1820 return;
1821 }
1822
1823 const preparedEntry = this.smaskPreparedEntry;
1824 if (preparedEntry) {
1825 // Fast path: prepared-mask destination-in drawImage. See
1826 // smaskPreparedOOBAlpha field doc for the OOB handling table.
1827 let clipX = layerOffsetX;
1828 let clipY = layerOffsetY;
1829 let clipW = layerWidth;
1830 let clipH = layerHeight;
1831 const oobAlpha = this.smaskPreparedOOBAlpha;
1832 const hasOOBAlpha = oobAlpha !== null;
1833 if (hasOOBAlpha) {
1834 clipX = Math.max(layerOffsetX, smask.offsetX);
1835 clipY = Math.max(layerOffsetY, smask.offsetY);
1836 const x1 = Math.min(
1837 layerOffsetX + layerWidth,
1838 smask.offsetX + smask.canvas.width
1839 );
1840 const y1 = Math.min(
1841 layerOffsetY + layerHeight,
1842 smask.offsetY + smask.canvas.height
1843 );
1844 clipW = x1 - clipX;
1845 clipH = y1 - clipY;
1846 }
1847 if (clipW > 0 && clipH > 0) {
1848 const srcX = clipX - this.smaskPreparedOffsetX;
1849 const srcY = clipY - this.smaskPreparedOffsetY;
1850 layerCtx.save();
1851 layerCtx.globalAlpha = 1;
1852 layerCtx.setTransform(1, 0, 0, 1, 0, 0);
1853 const clip = new Path2D();
1854 clip.rect(clipX, clipY, clipW, clipH);
1855 layerCtx.clip(clip);
1856 layerCtx.globalCompositeOperation = "destination-in";
1857 layerCtx.drawImage(
1858 preparedEntry.canvas,
1859 srcX,
1860 srcY,
1861 clipW,
1862 clipH,
1863 clipX,
1864 clipY,
1865 clipW,
1866 clipH
1867 );
1868 layerCtx.restore();
1869 }
1870 if (hasOOBAlpha && oobAlpha < 255) {
1871 this._applySMaskOOBAlpha(
1872 layerCtx,
1873 layerOffsetX,
1874 layerOffsetY,
1875 layerWidth,
1876 layerHeight,
1877 clipX,
1878 clipY,
1879 clipX + clipW,
1880 clipY + clipH,
1881 oobAlpha
1882 );
1883 }
1884 } else {
1885 this.genericComposeSMask(
1886 smask,
1887 layerCtx,
1888 layerWidth,
1889 layerHeight,
1890 layerOffsetX,
1891 layerOffsetY
1892 );
1893 }
1894
1895 if (!ctx) {
1896 return;
1897 }
1898
1899 ctx.save();
1900 ctx.globalAlpha = 1;
1901 ctx.globalCompositeOperation = smask.blendMode || "source-over";
1902 ctx.setTransform(1, 0, 0, 1, 0, 0);
1903 // Blit only the dirty box -- the rest of the scratch canvas was
1904 // cleared in compose().
1905 ctx.drawImage(
1906 layerCtx.canvas,
1907 layerOffsetX,
1908 layerOffsetY,
1909 layerWidth,
1910 layerHeight,
1911 layerOffsetX,
1912 layerOffsetY,
1913 layerWidth,
1914 layerHeight
1915 );
1916 ctx.restore();
1917 }
1918
1919 /**
1920 * Fade the dirty box's OOB region by a constant alpha. Called from
1921 * composeSMask when smaskPreparedOOBAlpha is in (0, 255).
1922 *
1923 * destination-in clears every destination pixel outside the source's
1924 * footprint, so four fillRects (one per strip) would each clear the
1925 * others. Instead one fillRect covers the dirty box, restricted by
1926 * an even-odd clip enclosing exactly (dirty_box XOR mask_region);
1927 * within the clip the source covers everything so no "outside
1928 * source" pixels exist.
1929 */
1930 _applySMaskOOBAlpha(
1931 layerCtx,
1932 layerOffsetX,
1933 layerOffsetY,
1934 layerWidth,
1935 layerHeight,
1936 maskX0,
1937 maskY0,
1938 maskX1,
1939 maskY1,
1940 alpha
1941 ) {
1942 const hasInnerCutout = maskX0 < maskX1 && maskY0 < maskY1;
1943 if (
1944 hasInnerCutout &&
1945 maskX0 === layerOffsetX &&
1946 maskY0 === layerOffsetY &&
1947 maskX1 === layerOffsetX + layerWidth &&
1948 maskY1 === layerOffsetY + layerHeight
1949 ) {
1950 // Dirty box is entirely inside the mask -- no OOB region to fade.
1951 return;
1952 }
1953 const path = new Path2D();
1954 path.rect(layerOffsetX, layerOffsetY, layerWidth, layerHeight);
1955 if (hasInnerCutout) {
1956 path.rect(maskX0, maskY0, maskX1 - maskX0, maskY1 - maskY0);
1957 }
1958
1959 layerCtx.save();
1960 layerCtx.globalAlpha = alpha / 255;
1961 layerCtx.setTransform(1, 0, 0, 1, 0, 0);
1962 layerCtx.clip(path, "evenodd");
1963 layerCtx.globalCompositeOperation = "destination-in";
1964 // MUST be fully opaque -- destination-in scales dst_a by src_a, and
1965 // globalAlpha must be the only thing scaling source alpha.
1966 layerCtx.fillStyle = "#000000";
1967 layerCtx.fillRect(layerOffsetX, layerOffsetY, layerWidth, layerHeight);
1968 layerCtx.restore();
1969 }
1970
1971 genericComposeSMask(
1972 smask,
1973 layerCtx,
1974 width,
1975 height,
1976 layerOffsetX,
1977 layerOffsetY
1978 ) {
1979 // composeSMask helper, reached only for plain-alpha masks (no
1980 // filter, no backdrop); every backdrop/filter case prebakes in
1981 // _prepareSMaskCanvas. A single destination-in blit suffices:
1982 // transparent OOB mask samples clear OOB layer pixels.
1983 const {
1984 context: maskCtx,
1985 offsetX: maskOffsetX,
1986 offsetY: maskOffsetY,
1987 } = smask;
1988
1989 layerCtx.save();
1990 layerCtx.globalAlpha = 1;
1991 layerCtx.setTransform(1, 0, 0, 1, 0, 0);
1992 const clip = new Path2D();
1993 clip.rect(layerOffsetX, layerOffsetY, width, height);
1994 layerCtx.clip(clip);
1995 layerCtx.globalCompositeOperation = "destination-in";
1996 layerCtx.drawImage(
1997 maskCtx.canvas,
1998 layerOffsetX - maskOffsetX,
1999 layerOffsetY - maskOffsetY,
2000 width,
2001 height,
2002 layerOffsetX,
2003 layerOffsetY,
2004 width,
2005 height
2006 );
2007 layerCtx.restore();
2008 }
2009
2010 save(opIdx) {
2011 if (this.inSMaskMode) {
2012 // SMask mode may be turned on/off causing us to lose graphics state.
2013 // Copy the temporary canvas state to the main(suspended) canvas to keep
2014 // it in sync.
2015 copyCtxState(this.ctx, this.suspendedCtx);
2016 }
2017 this.ctx.save();
2018 const old = this.current;
2019 this.stateStack.push(old);
2020 this.current = old.clone();
2021 this.dependencyTracker?.save(opIdx);
2022 }
2023
2024 restore(opIdx) {
2025 this.dependencyTracker?.restore(opIdx);
2026
2027 if (this.stateStack.length === 0) {
2028 if (this.inSMaskMode) {
2029 this.endSMaskMode();
2030 }
2031 return;
2032 }
2033
2034 this.current = this.stateStack.pop();
2035 this.ctx.restore();
2036 if (this.inSMaskMode) {
2037 // Graphics state is stored on the main(suspended) canvas. Restore its
2038 // state then copy it over to the temporary canvas.
2039 copyCtxState(this.suspendedCtx, this.ctx);
2040 // The scratch canvas may have been freshly created by beginSMaskMode
2041 // (called from checkSMaskState during a previous endGroup), in which
2042 // case its save/restore stack is empty and ctx.restore() above was a
2043 // no-op. Explicitly sync the CTM from the main canvas so that any CTM
2044 // change that the mirrored restore applied to the main canvas is also
2045 // reflected on the scratch canvas.
2046 this.ctx.setTransform(this.suspendedCtx.getTransform());
2047 }
2048 this.checkSMaskState(opIdx);
2049
2050 // Ensure that the clipping path is reset (fixes issue6413.pdf).
2051 this.pendingClip = null;
2052
2053 this._cachedScaleForStroking[0] = -1;
2054 this._cachedGetSinglePixelWidth = null;
2055 }
2056
2057 transform(opIdx, a, b, c, d, e, f) {
2058 this.dependencyTracker?.recordIncrementalData("transform", opIdx);
2059 this.ctx.transform(a, b, c, d, e, f);
2060
2061 this._cachedScaleForStroking[0] = -1;
2062 this._cachedGetSinglePixelWidth = null;
2063 }
2064
2065 // Path
2066 constructPath(opIdx, op, data, minMax) {
2067 let [path] = data;
2068 if (!minMax) {
2069 // The path is empty, so no need to update the current minMax.
2070 path ||= data[0] = new Path2D();
2071 if (op !== OPS.stroke && op !== OPS.closeStroke) {
2072 this.current.tilingPatternDims = null;
2073 }
2074 this[op](opIdx, path);
2075 return;
2076 }
2077
2078 if (this.dependencyTracker !== null) {
2079 const outerExtraSize = op === OPS.stroke ? this.current.lineWidth / 2 : 0;
2080 this.dependencyTracker
2081 .resetBBox(opIdx)
2082 .recordBBox(
2083 opIdx,
2084 this.ctx,
2085 minMax[0] - outerExtraSize,
2086 minMax[2] + outerExtraSize,
2087 minMax[1] - outerExtraSize,
2088 minMax[3] + outerExtraSize
2089 )
2090 .recordDependencies(opIdx, ["transform"]);
2091 }
2092
2093 if (!(path instanceof Path2D)) {
2094 path = data[0] = makePathFromDrawOPS(path);
2095 }
2096 Util.axialAlignedBoundingBox(
2097 minMax,
2098 getCurrentTransform(this.ctx),
2099 this.current.minMax
2100 );
2101
2102 const tilingDims = this.current.tilingPatternDims;
2103 if (
2104 tilingDims &&
2105 op !== OPS.stroke &&
2106 op !== OPS.closeStroke &&
2107 this.current.fillColor instanceof TilingPattern
2108 ) {
2109 // Intersect with clip box to get the actual fill area, then convert
2110 // to pattern space.
2111 const clippedBBox = Util.intersect(
2112 this.current.clipBox,
2113 this.current.minMax
2114 );
2115 if (!clippedBBox) {
2116 this.current.tilingPatternDims = null;
2117 } else {
2118 this.current.fillColor.updatePatternDims(clippedBBox, tilingDims);
2119 }
2120 }
2121
2122 this[op](opIdx, path);
2123
2124 this._pathStartIdx = opIdx;
2125 }
2126
2127 closePath(opIdx) {
2128 this.ctx.closePath();
2129 }
2130
2131 stroke(opIdx, path, consumePath = true) {
2132 const started =
2133 consumePath && this.#beginKnockoutElement(this.current.strokeAlpha);
2134 const ctx = this.ctx;
2135 const strokeColor = this.current.strokeColor;
2136 // For stroke we want to temporarily change the global alpha to the
2137 // stroking alpha.
2138 ctx.globalAlpha = this.current.strokeAlpha;
2139 if (this.contentVisible) {
2140 if (typeof strokeColor === "object" && strokeColor?.getPattern) {
2141 const baseTransform = strokeColor.isModifyingCurrentTransform()
2142 ? ctx.getTransform()
2143 : null;
2144 ctx.save();
2145 ctx.strokeStyle = strokeColor.getPattern(
2146 ctx,
2147 this,
2148 getCurrentTransformInverse(ctx),
2149 PathType.STROKE,
2150 opIdx
2151 );
2152 if (baseTransform) {
2153 const newPath = new Path2D();
2154 newPath.addPath(
2155 path,
2156 ctx.getTransform().invertSelf().multiplySelf(baseTransform)
2157 );
2158 path = newPath;
2159 }
2160 this.rescaleAndStroke(path, /* saveRestore */ false);
2161 ctx.restore();
2162 } else {
2163 this.rescaleAndStroke(path, /* saveRestore */ true);
2164 }
2165 }
2166
2167 this.dependencyTracker?.recordDependencies(opIdx, Dependencies.stroke);
2168
2169 if (consumePath) {
2170 this.consumePath(
2171 opIdx,
2172 path,
2173 this.current.getClippedPathBoundingBox(
2174 PathType.STROKE,
2175 getCurrentTransform(this.ctx)
2176 )
2177 );
2178 }
2179
2180 // Restore the global alpha to the fill alpha
2181 ctx.globalAlpha = this.current.fillAlpha;
2182 this.#endKnockoutElement(started);
2183 }
2184
2185 closeStroke(opIdx, path) {
2186 this.stroke(opIdx, path);
2187 }
2188
2189 fill(opIdx, path, consumePath = true) {
2190 const started =
2191 consumePath && this.#beginKnockoutElement(this.current.fillAlpha);
2192 const ctx = this.ctx;
2193 const fillColor = this.current.fillColor;
2194 const isPatternFill = this.current.patternFill;
2195 let needRestore = false;
2196 const intersect = this.current.getClippedPathBoundingBox();
2197
2198 this.dependencyTracker?.recordDependencies(opIdx, Dependencies.fill);
2199
2200 if (isPatternFill) {
2201 const dims = this.current.tilingPatternDims;
2202 const tileIdx = dims && fillColor.canSkipPatternCanvas(dims);
2203 if (tileIdx) {
2204 // Draw the tile directly, skipping the pattern canvas.
2205 fillColor.drawPattern(this, path, this.pendingEOFill, tileIdx, opIdx);
2206 this.pendingEOFill = false;
2207 if (consumePath) {
2208 this.consumePath(opIdx, path, intersect);
2209 }
2210 this.current.tilingPatternDims = null;
2211 this.#endKnockoutElement(started);
2212 return;
2213 }
2214 const baseTransform = fillColor.isModifyingCurrentTransform()
2215 ? ctx.getTransform()
2216 : null;
2217 this.dependencyTracker?.save(opIdx);
2218 ctx.save();
2219 ctx.fillStyle = fillColor.getPattern(
2220 ctx,
2221 this,
2222 getCurrentTransformInverse(ctx),
2223 PathType.FILL,
2224 opIdx
2225 );
2226 if (baseTransform) {
2227 const newPath = new Path2D();
2228 newPath.addPath(
2229 path,
2230 ctx.getTransform().invertSelf().multiplySelf(baseTransform)
2231 );
2232 path = newPath;
2233 }
2234 needRestore = true;
2235 }
2236
2237 if (this.contentVisible && intersect !== null) {
2238 if (this.pendingEOFill) {
2239 ctx.fill(path, "evenodd");
2240 this.pendingEOFill = false;
2241 } else {
2242 ctx.fill(path);
2243 }
2244 }
2245
2246 if (needRestore) {
2247 ctx.restore();
2248 this.dependencyTracker?.restore(opIdx);
2249 }
2250 if (consumePath) {
2251 this.consumePath(opIdx, path, intersect);
2252 }
2253 this.#endKnockoutElement(started);
2254 }
2255
2256 eoFill(opIdx, path) {
2257 this.pendingEOFill = true;
2258 this.fill(opIdx, path);
2259 }
2260
2261 fillStroke(opIdx, path) {
2262 // Fill and stroke share one KO element so they composite against the
2263 // initial backdrop once, not twice. Use the smaller of the two alpha_s as
2264 // the mask divisor: it's conservative (over-clamps the other pass's
2265 // mask towards 1) but keeps the mask coverage at least as large as the
2266 // union of fill+stroke shapes, which is what KO erasure wants.
2267 const started = this.#beginKnockoutElement(
2268 Math.min(this.current.fillAlpha, this.current.strokeAlpha)
2269 );
2270 this.fill(opIdx, path, false);
2271 this.stroke(opIdx, path, false);
2272 this.consumePath(opIdx, path);
2273 this.#endKnockoutElement(started);
2274 }
2275
2276 eoFillStroke(opIdx, path) {
2277 this.pendingEOFill = true;
2278 this.fillStroke(opIdx, path);
2279 }
2280
2281 closeFillStroke(opIdx, path) {
2282 this.fillStroke(opIdx, path);
2283 }
2284
2285 closeEOFillStroke(opIdx, path) {
2286 this.pendingEOFill = true;
2287 this.fillStroke(opIdx, path);
2288 }
2289
2290 endPath(opIdx, path) {
2291 this.consumePath(opIdx, path);
2292 }
2293
2294 rawFillPath(opIdx, path) {
2295 const started = this.#beginKnockoutElement(this.current.fillAlpha);
2296 this.ctx.fill(path);
2297 this.dependencyTracker
2298 ?.recordDependencies(opIdx, Dependencies.rawFillPath)
2299 .recordOperation(opIdx);
2300 this.#endKnockoutElement(started);
2301 }
2302
2303 // Clipping
2304 clip(opIdx) {
2305 this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx);
2306 this.pendingClip = NORMAL_CLIP;
2307 }
2308
2309 eoClip(opIdx) {
2310 this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx);
2311 this.pendingClip = EO_CLIP;
2312 }
2313
2314 // Text
2315 beginText(opIdx) {
2316 this.current.textMatrix = null;
2317 this.current.textMatrixScale = 1;
2318 this.current.x = this.current.lineX = 0;
2319 this.current.y = this.current.lineY = 0;
2320
2321 this.dependencyTracker
2322 ?.recordOpenMarker(opIdx)
2323 .resetIncrementalData("sameLineText")
2324 .resetIncrementalData("moveText", opIdx);
2325 }
2326
2327 endText(opIdx) {
2328 const paths = this.pendingTextPaths;
2329 const ctx = this.ctx;
2330
2331 if (this.dependencyTracker) {
2332 const { dependencyTracker } = this;
2333 if (paths !== undefined) {
2334 dependencyTracker
2335 .recordFutureForcedDependency(
2336 "textClip",
2337 dependencyTracker.getOpenMarker()
2338 )
2339 .recordFutureForcedDependency("textClip", opIdx);
2340 }
2341 dependencyTracker.recordCloseMarker(opIdx);
2342 }
2343
2344 if (paths !== undefined) {
2345 const newPath = new Path2D();
2346 const invTransf = ctx.getTransform().invertSelf();
2347 for (const { transform, x, y, fontSize, path } of paths) {
2348 if (!path) {
2349 continue; // Skip empty paths.
2350 }
2351 newPath.addPath(
2352 path,
2353 new DOMMatrix(transform)
2354 .preMultiplySelf(invTransf)
2355 .translate(x, y)
2356 .scale(fontSize, -fontSize)
2357 );
2358 }
2359
2360 ctx.clip(newPath);
2361 }
2362 delete this.pendingTextPaths;
2363 }
2364
2365 setCharSpacing(opIdx, spacing) {
2366 this.dependencyTracker?.recordSimpleData("charSpacing", opIdx);
2367 this.current.charSpacing = spacing;
2368 }
2369
2370 setWordSpacing(opIdx, spacing) {
2371 this.dependencyTracker?.recordSimpleData("wordSpacing", opIdx);
2372 this.current.wordSpacing = spacing;
2373 }
2374
2375 setHScale(opIdx, scale) {
2376 this.dependencyTracker?.recordSimpleData("hScale", opIdx);
2377 this.current.textHScale = scale / 100;
2378 }
2379
2380 setLeading(opIdx, leading) {
2381 this.dependencyTracker?.recordSimpleData("leading", opIdx);
2382 this.current.leading = -leading;
2383 }
2384
2385 setFont(opIdx, fontRefName, size) {
2386 this.dependencyTracker
2387 ?.recordSimpleData("font", opIdx)
2388 .recordSimpleDataFromNamed("fontObj", fontRefName, opIdx);
2389 const fontObj = this.commonObjs.get(fontRefName);
2390 const current = this.current;
2391
2392 if (!fontObj) {
2393 throw new Error(`Can't find font for ${fontRefName}`);
2394 }
2395 current.fontMatrix = fontObj.fontMatrix || FONT_IDENTITY_MATRIX;
2396
2397 // A valid matrix needs all main diagonal elements to be non-zero
2398 // This also ensures we bypass FF bugzilla bug #719844.
2399 if (current.fontMatrix[0] === 0 || current.fontMatrix[3] === 0) {
2400 warn("Invalid font matrix for font " + fontRefName);
2401 }
2402
2403 // The spec for Tf (setFont) says that 'size' specifies the font 'scale',
2404 // and in some docs this can be negative (inverted x-y axes).
2405 if (size < 0) {
2406 size = -size;
2407 current.fontDirection = -1;
2408 } else {
2409 current.fontDirection = 1;
2410 }
2411
2412 this.current.font = fontObj;
2413 this.current.fontSize = size;
2414
2415 if (fontObj.isType3Font) {
2416 return; // we don't need ctx.font for Type3 fonts
2417 }
2418
2419 const name = fontObj.loadedName || "sans-serif";
2420 const typeface =
2421 fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;
2422
2423 let bold = "normal";
2424 if (fontObj.black) {
2425 bold = "900";
2426 } else if (fontObj.bold) {
2427 bold = "bold";
2428 }
2429 const italic = fontObj.italic ? "italic" : "normal";
2430
2431 // Some font backends cannot handle fonts below certain size.
2432 // Keeping the font at minimal size and using the fontSizeScale to change
2433 // the current transformation matrix before the fillText/strokeText.
2434 // See https://bugzilla.mozilla.org/show_bug.cgi?id=726227
2435 const browserFontSize = MathClamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE);
2436 this.current.fontSizeScale = size / browserFontSize;
2437
2438 this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;
2439 }
2440
2441 setTextRenderingMode(opIdx, mode) {
2442 this.dependencyTracker?.recordSimpleData("textRenderingMode", opIdx);
2443 this.current.textRenderingMode = mode;
2444 }
2445
2446 setTextRise(opIdx, rise) {
2447 this.dependencyTracker?.recordSimpleData("textRise", opIdx);
2448 this.current.textRise = rise;
2449 }
2450
2451 moveText(opIdx, x, y) {
2452 this.dependencyTracker
2453 ?.resetIncrementalData("sameLineText")
2454 .recordIncrementalData("moveText", opIdx);
2455 this.current.x = this.current.lineX += x;
2456 this.current.y = this.current.lineY += y;
2457 }
2458
2459 setLeadingMoveText(opIdx, x, y) {
2460 this.setLeading(opIdx, -y);
2461 this.moveText(opIdx, x, y);
2462 }
2463
2464 setTextMatrix(opIdx, matrix) {
2465 this.dependencyTracker
2466 ?.resetIncrementalData("sameLineText")
2467 .recordSimpleData("textMatrix", opIdx);
2468 const { current } = this;
2469 current.textMatrix = matrix;
2470 current.textMatrixScale = Math.hypot(matrix[0], matrix[1]);
2471
2472 current.x = current.lineX = 0;
2473 current.y = current.lineY = 0;
2474 }
2475
2476 nextLine(opIdx) {
2477 this.moveText(opIdx, 0, this.current.leading);
2478
2479 this.dependencyTracker?.recordIncrementalData(
2480 "moveText",
2481 // 'leading' affects 'nextLine' operations. Rather than dealing
2482 // with transitive dependencies, just mark everything that depends on
2483 // the 'moveText' operation as depending on the 'leading' value.
2484 this.dependencyTracker.getSimpleIndex("leading") ?? opIdx
2485 );
2486 }
2487
2488 #getScaledPath(path, currentTransform, transform) {
2489 const newPath = new Path2D();
2490 newPath.addPath(
2491 path,
2492 new DOMMatrix(transform).invertSelf().multiplySelf(currentTransform)
2493 );
2494 return newPath;
2495 }
2496
2497 paintChar(
2498 opIdx,
2499 character,
2500 x,
2501 y,
2502 patternFillTransform,
2503 patternStrokeTransform
2504 ) {
2505 const ctx = this.ctx;
2506 const current = this.current;
2507 const font = current.font;
2508 const textRenderingMode = current.textRenderingMode;
2509 const fontSize = current.fontSize / current.fontSizeScale;
2510 const fillStrokeMode =
2511 textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
2512 const isAddToPathSet = !!(
2513 textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG
2514 );
2515 const patternFill = current.patternFill && !font.missingFile;
2516 const patternStroke = current.patternStroke && !font.missingFile;
2517
2518 let path;
2519 if (
2520 (font.disableFontFace ||
2521 isAddToPathSet ||
2522 patternFill ||
2523 patternStroke) &&
2524 !font.missingFile
2525 ) {
2526 path = font.getPathGenerator(this.commonObjs, character);
2527 }
2528
2529 if (path && (font.disableFontFace || patternFill || patternStroke)) {
2530 ctx.save();
2531 ctx.translate(x, y);
2532 ctx.scale(fontSize, -fontSize);
2533
2534 this.dependencyTracker?.recordCharacterBBox(opIdx, ctx, font);
2535
2536 let currentTransform;
2537 if (
2538 fillStrokeMode === TextRenderingMode.FILL ||
2539 fillStrokeMode === TextRenderingMode.FILL_STROKE
2540 ) {
2541 if (patternFillTransform) {
2542 currentTransform = ctx.getTransform();
2543 ctx.setTransform(...patternFillTransform);
2544 const scaledPath = this.#getScaledPath(
2545 path,
2546 currentTransform,
2547 patternFillTransform
2548 );
2549 ctx.fill(scaledPath);
2550 } else {
2551 ctx.fill(path);
2552 }
2553 }
2554 if (
2555 fillStrokeMode === TextRenderingMode.STROKE ||
2556 fillStrokeMode === TextRenderingMode.FILL_STROKE
2557 ) {
2558 if (patternStrokeTransform) {
2559 currentTransform ||= ctx.getTransform();
2560 ctx.setTransform(...patternStrokeTransform);
2561 const { a, b, c, d } = currentTransform;
2562 const invPatternTransform = Util.inverseTransform(
2563 patternStrokeTransform
2564 );
2565 const transf = Util.transform(
2566 [a, b, c, d, 0, 0],
2567 invPatternTransform
2568 );
2569 Util.singularValueDecompose2dScale(transf, XY);
2570
2571 // Cancel the pattern scaling of the line width.
2572 // If sx and sy are different, unfortunately we can't do anything and
2573 // we'll have a rendering bug.
2574 ctx.lineWidth *= Math.max(XY[0], XY[1]) / fontSize;
2575 ctx.stroke(
2576 this.#getScaledPath(path, currentTransform, patternStrokeTransform)
2577 );
2578 } else {
2579 ctx.lineWidth /= fontSize;
2580 ctx.stroke(path);
2581 }
2582 }
2583 ctx.restore();
2584 } else {
2585 if (
2586 fillStrokeMode === TextRenderingMode.FILL ||
2587 fillStrokeMode === TextRenderingMode.FILL_STROKE
2588 ) {
2589 ctx.fillText(character, x, y);
2590 this.dependencyTracker?.recordCharacterBBox(
2591 opIdx,
2592 ctx,
2593 font,
2594 fontSize,
2595 x,
2596 y,
2597 () => ctx.measureText(character)
2598 );
2599 }
2600 if (
2601 fillStrokeMode === TextRenderingMode.STROKE ||
2602 fillStrokeMode === TextRenderingMode.FILL_STROKE
2603 ) {
2604 if (this.dependencyTracker) {
2605 this.dependencyTracker
2606 ?.recordCharacterBBox(opIdx, ctx, font, fontSize, x, y, () =>
2607 ctx.measureText(character)
2608 )
2609 .recordDependencies(opIdx, Dependencies.stroke);
2610 }
2611 ctx.strokeText(character, x, y);
2612 }
2613 }
2614
2615 if (isAddToPathSet) {
2616 const paths = (this.pendingTextPaths ||= []);
2617 paths.push({
2618 transform: getCurrentTransform(ctx),
2619 x,
2620 y,
2621 fontSize,
2622 path,
2623 });
2624 this.dependencyTracker?.recordCharacterBBox(
2625 opIdx,
2626 ctx,
2627 font,
2628 fontSize,
2629 x,
2630 y
2631 );
2632 }
2633 }
2634
2635 get isFontSubpixelAAEnabled() {
2636 // Checks if anti-aliasing is enabled when scaled text is painted.
2637 // On Windows GDI scaled fonts looks bad.
2638 const tmpCanvas = this.canvasFactory.create(10, 10);
2639 const ctx = tmpCanvas.context;
2640 ctx.scale(1.5, 1);
2641 ctx.fillText("I", 0, 10);
2642 const data = ctx.getImageData(0, 0, 10, 10).data;
2643 this.canvasFactory.destroy(tmpCanvas);
2644 let enabled = false;
2645 for (let i = 3; i < data.length; i += 4) {
2646 if (data[i] > 0 && data[i] < 255) {
2647 enabled = true;
2648 break;
2649 }
2650 }
2651 return shadow(this, "isFontSubpixelAAEnabled", enabled);
2652 }
2653
2654 showText(opIdx, glyphs) {
2655 if (this.dependencyTracker) {
2656 this.dependencyTracker
2657 .recordDependencies(opIdx, Dependencies.showText)
2658 .resetBBox(opIdx);
2659 if (this.current.textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG) {
2660 this.dependencyTracker
2661 .recordFutureForcedDependency("textClip", opIdx)
2662 .inheritPendingDependenciesAsFutureForcedDependencies();
2663 }
2664 }
2665
2666 const current = this.current;
2667 const font = current.font;
2668 if (font.isType3Font) {
2669 const started = this.#beginKnockoutElement(current.fillAlpha);
2670 this.showType3Text(opIdx, glyphs);
2671 this.dependencyTracker?.recordShowTextOperation(opIdx);
2672 this.#endKnockoutElement(started);
2673 return;
2674 }
2675
2676 const fontSize = current.fontSize;
2677 if (fontSize === 0) {
2678 this.dependencyTracker?.recordOperation(opIdx);
2679 return;
2680 }
2681
2682 const started = this.#beginKnockoutElement(current.fillAlpha);
2683 const ctx = this.ctx;
2684 const fontSizeScale = current.fontSizeScale;
2685 const charSpacing = current.charSpacing;
2686 const wordSpacing = current.wordSpacing;
2687 const fontDirection = current.fontDirection;
2688 const textHScale = current.textHScale * fontDirection;
2689 const glyphsLength = glyphs.length;
2690 const vertical = font.vertical;
2691 const spacingDir = vertical ? 1 : -1;
2692 const defaultVMetrics = font.defaultVMetrics;
2693 const widthAdvanceScale = fontSize * current.fontMatrix[0];
2694
2695 const simpleFillText =
2696 current.textRenderingMode === TextRenderingMode.FILL &&
2697 !font.disableFontFace &&
2698 !current.patternFill;
2699
2700 ctx.save();
2701 if (current.textMatrix) {
2702 ctx.transform(...current.textMatrix);
2703 }
2704 ctx.translate(current.x, current.y + current.textRise);
2705
2706 if (fontDirection > 0) {
2707 ctx.scale(textHScale, -1);
2708 } else {
2709 ctx.scale(textHScale, 1);
2710 }
2711
2712 let patternFillTransform, patternStrokeTransform;
2713
2714 // Only compute pattern transforms if the text rendering mode actually
2715 // uses fill/stroke. This avoids expensive pattern calculations each call
2716 // when a patternFill/patternStroke is set, but unused.
2717 const fillStrokeMode =
2718 current.textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
2719 const needsFill =
2720 fillStrokeMode === TextRenderingMode.FILL ||
2721 fillStrokeMode === TextRenderingMode.FILL_STROKE;
2722 const needsStroke =
2723 fillStrokeMode === TextRenderingMode.STROKE ||
2724 fillStrokeMode === TextRenderingMode.FILL_STROKE;
2725
2726 let lineWidth = current.lineWidth;
2727 const scale = current.textMatrixScale;
2728 if (scale === 0 || lineWidth === 0) {
2729 if (needsStroke) {
2730 lineWidth = this.getSinglePixelWidth();
2731 }
2732 } else {
2733 lineWidth /= scale;
2734 }
2735
2736 if (fontSizeScale !== 1.0) {
2737 ctx.scale(fontSizeScale, fontSizeScale);
2738 lineWidth /= fontSizeScale;
2739 }
2740
2741 ctx.lineWidth = lineWidth;
2742
2743 if (needsFill && current.patternFill) {
2744 ctx.save();
2745 const pattern = current.fillColor.getPattern(
2746 ctx,
2747 this,
2748 getCurrentTransformInverse(ctx),
2749 PathType.FILL,
2750 opIdx
2751 );
2752 patternFillTransform = getCurrentTransform(ctx);
2753 ctx.restore();
2754 ctx.fillStyle = pattern;
2755 }
2756
2757 if (needsStroke && current.patternStroke) {
2758 ctx.save();
2759 const pattern = current.strokeColor.getPattern(
2760 ctx,
2761 this,
2762 getCurrentTransformInverse(ctx),
2763 PathType.STROKE,
2764 opIdx
2765 );
2766 patternStrokeTransform = getCurrentTransform(ctx);
2767 ctx.restore();
2768 ctx.strokeStyle = pattern;
2769 }
2770
2771 if (font.isInvalidPDFjsFont) {
2772 const chars = [];
2773 let width = 0;
2774 for (const glyph of glyphs) {
2775 chars.push(glyph.unicode);
2776 width += glyph.width;
2777 }
2778 const joinedChars = chars.join("");
2779 ctx.fillText(joinedChars, 0, 0);
2780 if (this.dependencyTracker !== null) {
2781 const measure = ctx.measureText(joinedChars);
2782 this.dependencyTracker
2783 .recordBBox(
2784 opIdx,
2785 this.ctx,
2786 -measure.actualBoundingBoxLeft,
2787 measure.actualBoundingBoxRight,
2788 -measure.actualBoundingBoxAscent,
2789 measure.actualBoundingBoxDescent
2790 )
2791 .recordShowTextOperation(opIdx);
2792 }
2793 current.x += width * widthAdvanceScale * textHScale;
2794 ctx.restore();
2795 this.compose();
2796 this.#endKnockoutElement(started);
2797 return;
2798 }
2799
2800 let x = 0,
2801 i;
2802 for (i = 0; i < glyphsLength; ++i) {
2803 const glyph = glyphs[i];
2804 if (typeof glyph === "number") {
2805 x += (spacingDir * glyph * fontSize) / 1000;
2806 continue;
2807 }
2808
2809 let restoreNeeded = false;
2810 const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
2811 const character = glyph.fontChar;
2812 const accent = glyph.accent;
2813 let scaledX, scaledY;
2814 let width = glyph.width;
2815 if (vertical) {
2816 const vmetric = glyph.vmetric || defaultVMetrics;
2817 const vx =
2818 -(glyph.vmetric ? vmetric[1] : width * 0.5) * widthAdvanceScale;
2819 const vy = vmetric[2] * widthAdvanceScale;
2820
2821 width = vmetric ? -vmetric[0] : width;
2822 scaledX = vx / fontSizeScale;
2823 scaledY = (x + vy) / fontSizeScale;
2824 } else {
2825 scaledX = x / fontSizeScale;
2826 scaledY = 0;
2827 }
2828
2829 let measure;
2830
2831 if (font.remeasure && width > 0) {
2832 measure = ctx.measureText(character);
2833
2834 // Some standard fonts may not have the exact width: rescale per
2835 // character if measured width is greater than expected glyph width
2836 // and subpixel-aa is enabled, otherwise just center the glyph.
2837 const measuredWidth =
2838 ((measure.width * 1000) / fontSize) * fontSizeScale;
2839 if (width < measuredWidth && this.isFontSubpixelAAEnabled) {
2840 const characterScaleX = width / measuredWidth;
2841 restoreNeeded = true;
2842 ctx.save();
2843 ctx.scale(characterScaleX, 1);
2844 scaledX /= characterScaleX;
2845 } else if (width !== measuredWidth) {
2846 scaledX +=
2847 (((width - measuredWidth) / 2000) * fontSize) / fontSizeScale;
2848 }
2849 }
2850
2851 // Only attempt to draw the glyph if it is actually in the embedded font
2852 // file or if there isn't a font file so the fallback font is shown.
2853 if (this.contentVisible && (glyph.isInFont || font.missingFile)) {
2854 if (simpleFillText && !accent) {
2855 // common case
2856 ctx.fillText(character, scaledX, scaledY);
2857
2858 this.dependencyTracker?.recordCharacterBBox(
2859 opIdx,
2860 ctx,
2861 // If we already measured the character, force usage of that
2862 measure ? { bbox: null } : font,
2863 fontSize / fontSizeScale,
2864 scaledX,
2865 scaledY,
2866 () => measure ?? ctx.measureText(character)
2867 );
2868 } else {
2869 this.paintChar(
2870 opIdx,
2871 character,
2872 scaledX,
2873 scaledY,
2874 patternFillTransform,
2875 patternStrokeTransform
2876 );
2877 if (accent) {
2878 const scaledAccentX =
2879 scaledX + (fontSize * accent.offset.x) / fontSizeScale;
2880 const scaledAccentY =
2881 scaledY - (fontSize * accent.offset.y) / fontSizeScale;
2882 this.paintChar(
2883 opIdx,
2884 accent.fontChar,
2885 scaledAccentX,
2886 scaledAccentY,
2887 patternFillTransform,
2888 patternStrokeTransform
2889 );
2890 }
2891 }
2892 }
2893
2894 const charWidth = vertical
2895 ? width * widthAdvanceScale - spacing * fontDirection
2896 : width * widthAdvanceScale + spacing * fontDirection;
2897 x += charWidth;
2898
2899 if (restoreNeeded) {
2900 ctx.restore();
2901 }
2902 }
2903 if (vertical) {
2904 current.y -= x;
2905 } else {
2906 current.x += x * textHScale;
2907 }
2908 ctx.restore();
2909 this.compose();
2910
2911 this.dependencyTracker?.recordShowTextOperation(opIdx);
2912 this.#endKnockoutElement(started);
2913 }
2914
2915 showType3Text(opIdx, glyphs) {
2916 // Type3 fonts - each glyph is a "mini-PDF"
2917 const ctx = this.ctx;
2918 const current = this.current;
2919 const font = current.font;
2920 const fontSize = current.fontSize;
2921 const fontDirection = current.fontDirection;
2922 const spacingDir = font.vertical ? 1 : -1;
2923 const charSpacing = current.charSpacing;
2924 const wordSpacing = current.wordSpacing;
2925 const textHScale = current.textHScale * fontDirection;
2926 const fontMatrix = current.fontMatrix || FONT_IDENTITY_MATRIX;
2927 const glyphsLength = glyphs.length;
2928 const isTextInvisible =
2929 current.textRenderingMode === TextRenderingMode.INVISIBLE;
2930 let i, glyph, width, spacingLength;
2931
2932 if (isTextInvisible || fontSize === 0) {
2933 return;
2934 }
2935 this._cachedScaleForStroking[0] = -1;
2936 this._cachedGetSinglePixelWidth = null;
2937
2938 ctx.save();
2939 if (current.textMatrix) {
2940 ctx.transform(...current.textMatrix);
2941 }
2942 ctx.translate(current.x, current.y + current.textRise);
2943
2944 ctx.scale(textHScale, fontDirection);
2945
2946 // Type3 fonts have their own operator list. Avoid mixing it up with the
2947 // dependency tracker of the main operator list.
2948 const dependencyTracker = this.dependencyTracker;
2949 this.dependencyTracker = dependencyTracker
2950 ? new CanvasNestedDependencyTracker(dependencyTracker, opIdx)
2951 : null;
2952
2953 for (i = 0; i < glyphsLength; ++i) {
2954 glyph = glyphs[i];
2955 if (typeof glyph === "number") {
2956 spacingLength = (spacingDir * glyph * fontSize) / 1000;
2957 this.ctx.translate(spacingLength, 0);
2958 current.x += spacingLength * textHScale;
2959 continue;
2960 }
2961
2962 const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
2963 const operatorList = font.charProcOperatorList.get(glyph.operatorListId);
2964 if (!operatorList) {
2965 warn(`Type3 character "${glyph.operatorListId}" is not available.`);
2966 } else if (this.contentVisible) {
2967 this.save();
2968 // A d0 (setCharWidth) glyph is colored (see Table 113 in pdf 1.7 specs)
2969 // unlike a d1 glyph painted as a stencil mask with the current fill
2970 // color. The constant alphas (ca/CA) are the opacity of that current
2971 // color, so they must not attenuate a d0 glyph.
2972 if (operatorList.fnArray[0] === OPS.setCharWidth) {
2973 current.fillAlpha = current.strokeAlpha = 1;
2974 ctx.globalAlpha = 1;
2975 }
2976 ctx.scale(fontSize, fontSize);
2977 ctx.transform(...fontMatrix);
2978 this.executeOperatorList(operatorList);
2979 this.restore();
2980 }
2981
2982 const p = [glyph.width, 0];
2983 Util.applyTransform(p, fontMatrix);
2984 width = p[0] * fontSize + spacing;
2985
2986 ctx.translate(width, 0);
2987 current.x += width * textHScale;
2988 }
2989 ctx.restore();
2990 if (dependencyTracker) {
2991 this.dependencyTracker = dependencyTracker;
2992 }
2993 }
2994
2995 // Type3 fonts
2996 setCharWidth(opIdx, xWidth, yWidth) {
2997 // We can safely ignore this since the width should be the same
2998 // as the width in the Widths array.
2999 }
3000
3001 setCharWidthAndBounds(opIdx, xWidth, yWidth, llx, lly, urx, ury) {
3002 const clip = new Path2D();
3003 clip.rect(llx, lly, urx - llx, ury - lly);
3004 this.ctx.clip(clip);
3005 this.dependencyTracker
3006 ?.recordBBox(opIdx, this.ctx, llx, urx, lly, ury)
3007 .recordClipBox(opIdx, this.ctx, llx, urx, lly, ury);
3008 this.endPath(opIdx);
3009 }
3010
3011 // Color
3012 getColorN_Pattern(opIdx, IR) {
3013 let pattern;
3014 if (IR[0] === "TilingPattern") {
3015 const baseTransform = this.baseTransform || getCurrentTransform(this.ctx);
3016 const canvasGraphicsFactory = {
3017 createCanvasGraphics: (ctx, renderingOpIdx) =>
3018 new CanvasGraphics(
3019 ctx,
3020 this.commonObjs,
3021 this.objs,
3022 this.canvasFactory,
3023 this.filterFactory,
3024 {
3025 optionalContentConfig: this.optionalContentConfig,
3026 markedContentStack: this.markedContentStack,
3027 },
3028 undefined,
3029 undefined,
3030 this.dependencyTracker
3031 ? new CanvasNestedDependencyTracker(
3032 this.dependencyTracker,
3033 renderingOpIdx,
3034 /* ignoreBBoxes */ true
3035 )
3036 : null
3037 ),
3038 };
3039 pattern = new TilingPattern(
3040 IR,
3041 this.ctx,
3042 canvasGraphicsFactory,
3043 baseTransform
3044 );
3045 } else {
3046 pattern = this._getPattern(opIdx, IR[1], IR[2]);
3047 }
3048 return pattern;
3049 }
3050
3051 setStrokeColorN(opIdx, ...args) {
3052 this.dependencyTracker?.recordSimpleData("strokeColor", opIdx);
3053 this.current.strokeColor = this.getColorN_Pattern(opIdx, args);
3054 this.current.patternStroke = true;
3055 }
3056
3057 setFillColorN(opIdx, ...args) {
3058 this.dependencyTracker?.recordSimpleData("fillColor", opIdx);
3059 const pattern = (this.current.fillColor = this.getColorN_Pattern(
3060 opIdx,
3061 args
3062 ));
3063 this.current.patternFill = true;
3064 this.current.tilingPatternDims =
3065 pattern instanceof TilingPattern ? [0, 0, 0, 0] : null;
3066 }
3067
3068 setStrokeRGBColor(opIdx, color) {
3069 this.dependencyTracker?.recordSimpleData("strokeColor", opIdx);
3070 this.ctx.strokeStyle = this.current.strokeColor = color;
3071 this.current.patternStroke = false;
3072 }
3073
3074 setStrokeTransparent(opIdx) {
3075 this.dependencyTracker?.recordSimpleData("strokeColor", opIdx);
3076 this.ctx.strokeStyle = this.current.strokeColor = "transparent";
3077 this.current.patternStroke = false;
3078 }
3079
3080 setFillRGBColor(opIdx, color) {
3081 this.dependencyTracker?.recordSimpleData("fillColor", opIdx);
3082 this.ctx.fillStyle = this.current.fillColor = color;
3083 this.current.patternFill = false;
3084 this.current.tilingPatternDims = null;
3085 }
3086
3087 setFillTransparent(opIdx) {
3088 this.dependencyTracker?.recordSimpleData("fillColor", opIdx);
3089 this.ctx.fillStyle = this.current.fillColor = "transparent";
3090 this.current.patternFill = false;
3091 this.current.tilingPatternDims = null;
3092 }
3093
3094 _getPattern(opIdx, objId, matrix = null) {
3095 const pattern = this.cachedPatterns.getOrInsertComputed(objId, () =>
3096 getShadingPattern(this.getObject(opIdx, objId))
3097 );
3098 if (matrix) {
3099 pattern.matrix = matrix;
3100 }
3101 return pattern;
3102 }
3103
3104 shadingFill(opIdx, objId) {
3105 if (!this.contentVisible) {
3106 return;
3107 }
3108 const started = this.#beginKnockoutElement(this.current.fillAlpha);
3109 const ctx = this.ctx;
3110
3111 this.save(opIdx);
3112 const pattern = this._getPattern(opIdx, objId);
3113 ctx.fillStyle = pattern.getPattern(
3114 ctx,
3115 this,
3116 getCurrentTransformInverse(ctx),
3117 PathType.SHADING,
3118 opIdx
3119 );
3120
3121 const inv = getCurrentTransformInverse(ctx);
3122 if (inv) {
3123 const { width, height } = ctx.canvas;
3124 const minMax = F32_BBOX_INIT.slice();
3125 Util.axialAlignedBoundingBox([0, 0, width, height], inv, minMax);
3126 const [x0, y0, x1, y1] = minMax;
3127
3128 this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
3129 } else {
3130 // HACK to draw the gradient onto an infinite rectangle.
3131 // PDF gradients are drawn across the entire image while
3132 // Canvas only allows gradients to be drawn in a rectangle
3133 // The following bug should allow us to remove this.
3134 // https://bugzilla.mozilla.org/show_bug.cgi?id=664884
3135
3136 this.ctx.fillRect(-1e10, -1e10, 2e10, 2e10);
3137 }
3138
3139 this.dependencyTracker
3140 ?.resetBBox(opIdx)
3141 // TODO: Track proper bbox
3142 .recordFullPageBBox(opIdx)
3143 .recordDependencies(opIdx, Dependencies.transform)
3144 .recordDependencies(opIdx, Dependencies.fill)
3145 .recordOperation(opIdx);
3146
3147 this.compose(this.current.getClippedPathBoundingBox());
3148 this.restore(opIdx);
3149 this.#endKnockoutElement(started);
3150 }
3151
3152 // Images
3153 beginInlineImage() {
3154 unreachable("Should not call beginInlineImage");
3155 }
3156
3157 beginImageData() {
3158 unreachable("Should not call beginImageData");
3159 }
3160
3161 paintFormXObjectBegin(opIdx, matrix, bbox) {
3162 if (!this.contentVisible) {
3163 return;
3164 }
3165 this.save(opIdx);
3166 this.baseTransformStack.push(this.baseTransform);
3167
3168 if (matrix) {
3169 this.transform(opIdx, ...matrix);
3170 }
3171 this.baseTransform = getCurrentTransform(this.ctx);
3172
3173 if (bbox) {
3174 Util.axialAlignedBoundingBox(
3175 bbox,
3176 this.baseTransform,
3177 this.current.minMax
3178 );
3179 const [x0, y0, x1, y1] = bbox;
3180 const clip = new Path2D();
3181 clip.rect(x0, y0, x1 - x0, y1 - y0);
3182 this.ctx.clip(clip);
3183 this.dependencyTracker?.recordClipBox(opIdx, this.ctx, x0, x1, y0, y1);
3184 this.endPath(opIdx);
3185 }
3186 }
3187
3188 paintFormXObjectEnd(opIdx) {
3189 if (!this.contentVisible) {
3190 return;
3191 }
3192 this.restore(opIdx);
3193 this.baseTransform = this.baseTransformStack.pop();
3194 }
3195
3196 beginGroup(opIdx, group) {
3197 if (!this.contentVisible) {
3198 return;
3199 }
3200
3201 this.save(opIdx);
3202 // If there's an active soft mask we don't want it enabled for the group, so
3203 // clear it out. The mask and suspended canvas will be restored in endGroup.
3204 const { inSMaskMode } = this;
3205 if (inSMaskMode) {
3206 this.endSMaskMode();
3207 this.current.activeSMask = null;
3208 }
3209
3210 const currentCtx = this.ctx;
3211 if (
3212 // A non-isolated group blends with its backdrop, so drawing it directly
3213 // on the parent canvas (rather than on a transparent intermediate one)
3214 // is correct even when it contains blend modes (bug 1873345). A soft
3215 // mask still needs its own canvas though, and an isolated group requires
3216 // a transparent backdrop, so both keep the intermediate canvas.
3217 (!group.needsIsolation || (!group.isolated && !group.hasSoftMask)) &&
3218 !group.knockout &&
3219 !group.isGray &&
3220 this.#knockoutGroupLevel === 0 &&
3221 currentCtx.globalAlpha === 1 &&
3222 currentCtx.globalCompositeOperation === "source-over" &&
3223 !inSMaskMode
3224 ) {
3225 if (group.bbox) {
3226 let clip = new Path2D();
3227 const [x0, y0, x1, y1] = group.bbox;
3228 clip.rect(x0, y0, x1 - x0, y1 - y0);
3229 if (group.matrix) {
3230 const path = new Path2D();
3231 path.addPath(clip, new DOMMatrix(group.matrix));
3232 clip = path;
3233 }
3234 currentCtx.clip(clip);
3235 }
3236 // Unlike the intermediate-canvas path below, the content is drawn
3237 // straight onto the parent canvas with no later compositing step, so the
3238 // inherited blend mode, alpha constants and transfer function must stay
3239 // active here rather than being reset (issue 20722); the conditions
3240 // above already guarantee a Normal blend and an opaque (ca === 1) state.
3241 this.groupStack.push(null); // null = no intermediate canvas
3242 this.#groupStackMeta.push(null);
3243 this.groupLevel++;
3244 return;
3245 }
3246
3247 // Reached only when the direct path above didn't apply, e.g. a soft mask,
3248 // non-default group alpha or blend mode: we still composite on a
3249 // transparent intermediate canvas rather than the real backdrop.
3250 if (!group.isolated && !group.knockout && this.#knockoutGroupLevel === 0) {
3251 info("TODO: Fully support non-isolated non-knockout groups.");
3252 }
3253
3254 const currentTransform = getCurrentTransform(currentCtx);
3255 if (group.matrix) {
3256 currentCtx.transform(...group.matrix);
3257 }
3258
3259 // Clip the bounding box to the current canvas.
3260 const canvasBounds = [
3261 0,
3262 0,
3263 currentCtx.canvas.width,
3264 currentCtx.canvas.height,
3265 ];
3266
3267 let bounds;
3268 if (group.bbox) {
3269 bounds = F32_BBOX_INIT.slice();
3270 Util.axialAlignedBoundingBox(
3271 group.bbox,
3272 getCurrentTransform(currentCtx),
3273 bounds
3274 );
3275
3276 bounds = Util.intersect(bounds, canvasBounds) || [0, 0, 0, 0];
3277 } else {
3278 bounds = canvasBounds;
3279 }
3280
3281 // Based on the current transform figure out how big the bounding box
3282 // will actually be.
3283 // Use ceil in case we're between sizes so we don't create canvas that is
3284 // too small and make the canvas at least 1x1 pixels.
3285 const offsetX = Math.floor(bounds[0]);
3286 const offsetY = Math.floor(bounds[1]);
3287 const drawnWidth = Math.max(Math.ceil(bounds[2]) - offsetX, 1);
3288 const drawnHeight = Math.max(Math.ceil(bounds[3]) - offsetY, 1);
3289
3290 this.current.startNewPathAndClipBox([0, 0, drawnWidth, drawnHeight]);
3291
3292 const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
3293 if (group.smask) {
3294 this.smaskGroupCanvases.push(scratchCanvas);
3295 }
3296 const groupCtx = scratchCanvas.context;
3297 // Non-isolated KO: keep a reference to the parent ctx (not a copy). It's
3298 // frozen while the group renders, so we can read from it on demand. The
3299 // backdrop is only restored under each element's footprint in
3300 // #compositeKnockoutSurface so it doesn't become part of the group
3301 // source itself.
3302 const backdropCtx = group.knockout && !group.isolated ? currentCtx : null;
3303 // Non-isolated non-KO subgroup inside a KO parent, with inner compositing
3304 // of its own: at endGroup we'll blend its elements against the outer KO
3305 // running canvas (also frozen), so just record the flag here and read
3306 // ctx.canvas at composite time.
3307 const hasInnerBackdrop =
3308 !group.isolated &&
3309 !group.knockout &&
3310 !group.smask &&
3311 group.needsIsolation &&
3312 this.#knockoutGroupLevel > 0;
3313
3314 // Pool the per-element shape mask for the lifetime of this KO group.
3315 // Non-KO groups never call #compositeKnockoutSurface for their own
3316 // elements so the entry is unused there.
3317 const knockoutMaskEntry = group.knockout
3318 ? this.canvasFactory.create(drawnWidth, drawnHeight)
3319 : null;
3320
3321 // For KO groups bump the level so inner elements get KO treatment; for
3322 // non-KO groups reset to 0 so an ancestor KO group doesn't apply to
3323 // them. Restored on endGroup.
3324 const savedKnockoutLevel = this.#knockoutGroupLevel;
3325 if (group.knockout) {
3326 this.#knockoutGroupLevel++;
3327 } else {
3328 this.#knockoutGroupLevel = 0;
3329 }
3330
3331 // Since we created a new canvas that is just the size of the bounding box
3332 // we have to translate the group ctx.
3333 groupCtx.translate(-offsetX, -offsetY);
3334 groupCtx.transform(...currentTransform);
3335
3336 const needsBackdropCopy =
3337 !group.isolated && !group.smask && group.needsIsolation;
3338 const replaceBackdrop =
3339 needsBackdropCopy &&
3340 !inSMaskMode &&
3341 savedKnockoutLevel === 0 &&
3342 !group.knockout &&
3343 !group.isGray &&
3344 group.hasSoftMask &&
3345 currentCtx.globalAlpha === 1 &&
3346 currentCtx.globalCompositeOperation === "source-over" &&
3347 this.current.transferMaps === "none";
3348 if (needsBackdropCopy && (inSMaskMode || replaceBackdrop)) {
3349 // A non-isolated group that needs isolation (because of an inner blend
3350 // mode and/or a soft mask) can't use the direct path above, so it
3351 // renders on a transparent intermediate canvas. Copy the current
3352 // backdrop into it so the inner blend modes (e.g. "multiply", "screen")
3353 // interact with the real background instead of transparency; otherwise a
3354 // /Multiply highlight, say, would be painted opaquely over the text
3355 // behind it, hiding it (bug 1873345 -- same as the direct path, but here
3356 // the soft mask forces an intermediate canvas).
3357 // This is needed both when entered from SMask mode and at the top level;
3358 // a non-isolated subgroup nested inside a knockout group instead gets its
3359 // backdrop through the hasInnerBackdrop path in endGroup, so it's
3360 // excluded here via savedKnockoutLevel.
3361 //
3362 // Outside SMask mode the copy is limited to a group that *would* have
3363 // qualified for the direct path (source-over, group alpha 1, not
3364 // knockout/gray) but was forced onto the intermediate canvas by its soft
3365 // mask (`hasSoftMask`). Restricting to that domain keeps the change
3366 // surgical:
3367 // - Only the soft-mask case is the bug 1873345 gap; a non-isolated
3368 // blend group pushed onto the intermediate canvas merely by a
3369 // non-default group alpha (no soft mask) is a separate pre-existing
3370 // case, left untouched to avoid changing its rendering (issue 13520).
3371 // - source-over/alpha 1 let endGroup replace the backdrop region with
3372 // the copied-and-updated result; compositing it normally would blend
3373 // partially transparent backdrop pixels twice. Under an outer blend
3374 // mode the outer blend would combine the backdrop with itself (e.g.
3375 // a /Multiply group with an inner soft mask darkens, issue 12798); a
3376 // non-1 group alpha would likewise mix the copied backdrop back in.
3377 // Those groups keep the transparent canvas and blend against the
3378 // real backdrop instead.
3379 // - isGray groups are grayscaled wholesale in endGroup, and an
3380 // inherited transfer filter (`transferMaps`) would be applied on
3381 // write-back, both of which would corrupt the copied backdrop, so
3382 // they're excluded too.
3383 groupCtx.save();
3384 groupCtx.setTransform(1, 0, 0, 1, 0, 0);
3385 groupCtx.drawImage(currentCtx.canvas, -offsetX, -offsetY);
3386 groupCtx.restore();
3387 }
3388
3389 // Apply the bbox to the group context.
3390 if (group.bbox) {
3391 let clip = new Path2D();
3392 const [x0, y0, x1, y1] = group.bbox;
3393 clip.rect(x0, y0, x1 - x0, y1 - y0);
3394 if (group.matrix) {
3395 const path = new Path2D();
3396 path.addPath(clip, new DOMMatrix(group.matrix));
3397 clip = path;
3398 }
3399 groupCtx.clip(clip);
3400 }
3401
3402 if (group.smask) {
3403 // Saving state and cached mask to be used in setGState.
3404 this.smaskStack.push({
3405 canvas: scratchCanvas.canvas,
3406 context: groupCtx,
3407 offsetX,
3408 offsetY,
3409 subtype: group.smask.subtype,
3410 backdrop: group.smask.backdrop,
3411 transferMap: group.smask.transferMap || null,
3412 });
3413 }
3414 if (
3415 !group.smask ||
3416 // When this is not an SMask group, we only need to update the current
3417 // transform if recording operations bboxes, so they the bboxes have the
3418 // correct transform applied.
3419 this.dependencyTracker
3420 ) {
3421 // Setup the current ctx so when the group is popped we draw it at the
3422 // right location.
3423 currentCtx.setTransform(1, 0, 0, 1, 0, 0);
3424 currentCtx.translate(offsetX, offsetY);
3425 currentCtx.save();
3426 }
3427 // The transparency group inherits all off the current graphics state
3428 // except the blend mode, soft mask, and alpha constants.
3429 copyCtxState(currentCtx, groupCtx);
3430 this.ctx = groupCtx;
3431 this.dependencyTracker
3432 ?.inheritSimpleDataAsFutureForcedDependencies([
3433 "fillAlpha",
3434 "strokeAlpha",
3435 "globalCompositeOperation",
3436 ])
3437 .pushBaseTransform(currentCtx);
3438 this.setGState(opIdx, [
3439 ["BM", "source-over"],
3440 ["ca", 1],
3441 ["CA", 1],
3442 ["TR", null],
3443 ]);
3444 this.groupStack.push(currentCtx);
3445 this.#groupStackMeta.push({
3446 backdropCtx,
3447 savedKnockoutLevel,
3448 offsetX,
3449 offsetY,
3450 hasInnerBackdrop,
3451 replaceBackdrop,
3452 knockoutMaskEntry,
3453 // Per-group scratch pools, lazily filled and freed in endGroup.
3454 knockoutTempEntry: null,
3455 knockoutBackdropEntry: null,
3456 });
3457 this.groupLevel++;
3458 }
3459
3460 endGroup(opIdx, group) {
3461 if (!this.contentVisible) {
3462 return;
3463 }
3464 this.groupLevel--;
3465 const groupCtx = this.ctx;
3466 const ctx = this.groupStack.pop();
3467 const groupMeta = this.#groupStackMeta.pop();
3468 // Restore the knockout level that was in effect before this group began.
3469 // Simple groups (groupMeta === null) never modify the level, so skip them.
3470 if (groupMeta) {
3471 this.#knockoutGroupLevel = groupMeta.savedKnockoutLevel;
3472 }
3473 if (ctx === null) {
3474 // Simple group: content was drawn directly on the parent canvas.
3475 this.restore(opIdx);
3476 return;
3477 }
3478
3479 if (group.isGray) {
3480 // The group color space is gray (a single component), so its rendered
3481 // content must be converted to grayscale before being composited onto
3482 // the parent canvas, see issue 7998.
3483 this.#convertGroupToGray(groupCtx);
3484 }
3485
3486 this.ctx = ctx;
3487 // Turn off image smoothing to avoid sub pixel interpolation which can
3488 // look kind of blurry for some pdfs.
3489 this.ctx.imageSmoothingEnabled = false;
3490
3491 this.dependencyTracker?.popBaseTransform();
3492
3493 if (group.smask) {
3494 this.tempSMask = this.smaskStack.pop();
3495 this.restore(opIdx);
3496 if (this.dependencyTracker) {
3497 this.ctx.restore();
3498 // beginSMaskMode() may have been called inside restore(opIdx) above
3499 // (via checkSMaskState), creating a fresh scratch canvas. If so,
3500 // the mirrored ctx.restore() just synced main's CTM but left the
3501 // scratch at the stale CTM set by beginSMaskMode(). Re-sync it.
3502 if (this.inSMaskMode) {
3503 this.ctx.setTransform(this.suspendedCtx.getTransform());
3504 }
3505 }
3506 this.#destroyKnockoutPools(groupMeta);
3507 } else {
3508 this.ctx.restore();
3509 const currentMtx = getCurrentTransform(this.ctx);
3510 this.restore(opIdx);
3511 this.ctx.save();
3512 this.ctx.setTransform(...currentMtx);
3513 const dirtyBox = F32_BBOX_INIT.slice();
3514 Util.axialAlignedBoundingBox(
3515 [0, 0, groupCtx.canvas.width, groupCtx.canvas.height],
3516 currentMtx,
3517 dirtyBox
3518 );
3519 const parentGroupMeta = this.#groupStackMeta.at(-1);
3520 if (this.#knockoutGroupLevel > 0) {
3521 // The subgroup is one element of the enclosing KO group, so
3522 // composite it with KO semantics. Two coord systems below:
3523 // - `currentMtx` (`destTransform`) places the subgroup canvas in
3524 // the parent on the final draw, like the non-KO `drawImage`.
3525 // - `groupMeta.offsetX/Y` are the pixel origins beginGroup stored
3526 // when sizing the scratch; we use them (not `currentMtx[4]/[5]`,
3527 // which are PDF-transform components) to crop the backdrop.
3528 if (groupMeta.hasInnerBackdrop) {
3529 // Non-isolated subgroup inside a KO parent: blend the elements
3530 // against the subgroup's own initial backdrop for colour, but use
3531 // the elements-only scratch as the alpha mask so transparent
3532 // areas don't erase the parent. `ctx` is the outer KO canvas
3533 // (just popped); its pixels still match the subgroup's
3534 // beginGroup state since the subgroup draws to its own scratch.
3535 const { width, height } = groupCtx.canvas;
3536 const colorEntry = this.canvasFactory.create(width, height);
3537 const colorCtx = colorEntry.context;
3538 colorCtx.drawImage(
3539 ctx.canvas,
3540 groupMeta.offsetX,
3541 groupMeta.offsetY,
3542 width,
3543 height,
3544 0,
3545 0,
3546 width,
3547 height
3548 );
3549 colorCtx.globalCompositeOperation = "source-over";
3550 colorCtx.drawImage(groupCtx.canvas, 0, 0);
3551 // Clip colorEntry to the subgroup's element footprint so
3552 // backdrop pixels outside the elements don't bleed onto the
3553 // parent. Built with alpha=1 (no scaling) so the mask uses the
3554 // subgroup's composited painted alpha directly as shape - its
3555 // global gstate alpha gets applied at the final draw below. The
3556 // mask is sized to the subgroup canvas, so we can't reuse the
3557 // parent KO group's pooled mask here; allocate a fresh entry
3558 // and reuse it for both the destination-in and the
3559 // destination-out below.
3560 const shapeMaskEntry = this.#createKnockoutMaskCanvas(
3561 groupCtx.canvas
3562 );
3563 colorCtx.globalCompositeOperation = "destination-in";
3564 colorCtx.drawImage(shapeMaskEntry.canvas, 0, 0);
3565 // Inline the isolated-path compositing here so we can share
3566 // shapeMaskEntry with the destination-in above.
3567 const sourceCompositeOperation = this.ctx.globalCompositeOperation;
3568 const sourceAlpha = this.ctx.globalAlpha;
3569 const sourceFilter = this.ctx.filter;
3570 this.ctx.save();
3571 this.ctx.setTransform(...currentMtx);
3572 this.ctx.globalAlpha = 1;
3573 if (FeatureTest.isCanvasFilterSupported) {
3574 this.ctx.filter = "none";
3575 }
3576 this.ctx.globalCompositeOperation = "destination-out";
3577 this.ctx.drawImage(shapeMaskEntry.canvas, 0, 0);
3578 this.ctx.globalCompositeOperation = sourceCompositeOperation;
3579 this.ctx.globalAlpha = sourceAlpha;
3580 if (FeatureTest.isCanvasFilterSupported) {
3581 this.ctx.filter = sourceFilter ?? "none";
3582 }
3583 this.ctx.drawImage(colorEntry.canvas, 0, 0);
3584 this.ctx.restore();
3585 this.canvasFactory.destroy(shapeMaskEntry);
3586 this.canvasFactory.destroy(colorEntry);
3587 } else {
3588 // For a non-isolated KO parent the backdrop lives one level up.
3589 // Compound the parent's and subgroup's offsets to crop it.
3590 const backdropCtx = parentGroupMeta?.backdropCtx ?? null;
3591 this.#compositeKnockoutSurface(this.ctx, groupCtx.canvas, {
3592 backdropCanvas: backdropCtx?.canvas ?? null,
3593 destTransform: currentMtx,
3594 backdropOffset: backdropCtx
3595 ? [
3596 parentGroupMeta.offsetX + groupMeta.offsetX,
3597 parentGroupMeta.offsetY + groupMeta.offsetY,
3598 ]
3599 : [0, 0],
3600 sourceAlpha: this.ctx.globalAlpha,
3601 sourceFilter: this.ctx.filter,
3602 });
3603 }
3604 } else {
3605 if (groupMeta.replaceBackdrop) {
3606 // "copy" clears the destination outside the source within the
3607 // current clip, so limit it to the intermediate canvas bounds.
3608 const clip = new Path2D();
3609 clip.rect(0, 0, groupCtx.canvas.width, groupCtx.canvas.height);
3610 this.ctx.clip(clip);
3611 this.ctx.globalCompositeOperation = "copy";
3612 }
3613 this.ctx.drawImage(groupCtx.canvas, 0, 0);
3614 }
3615 this.ctx.restore();
3616 this.canvasFactory.destroy({
3617 canvas: groupCtx.canvas,
3618 context: groupCtx,
3619 });
3620 this.#destroyKnockoutPools(groupMeta);
3621 this.compose(dirtyBox);
3622 }
3623 }
3624
3625 #convertGroupToGray(groupCtx) {
3626 const { canvas } = groupCtx;
3627 const { width, height } = canvas;
3628
3629 if (FeatureTest.isCanvasFilterSupported) {
3630 // Draw the canvas onto itself with the grayscale filter applied (which
3631 // preserves the alpha channel), using the "copy" composite operation so
3632 // the filtered content fully replaces the original.
3633 groupCtx.save();
3634 groupCtx.setTransform(1, 0, 0, 1, 0, 0);
3635 groupCtx.filter = "grayscale(1)";
3636 groupCtx.globalAlpha = 1;
3637 groupCtx.globalCompositeOperation = "copy";
3638 groupCtx.drawImage(canvas, 0, 0);
3639 groupCtx.restore();
3640 return;
3641 }
3642
3643 // Fallback when canvas filters aren't supported: convert each pixel to
3644 // grayscale by hand, using the same luminance coefficients as the
3645 // "grayscale(1)" filter while leaving the alpha channel untouched.
3646 const imageData = groupCtx.getImageData(0, 0, width, height);
3647 const { data } = imageData;
3648 for (let i = 0, ii = data.length; i < ii; i += 4) {
3649 const gray =
3650 (data[i] * 0.2126 + data[i + 1] * 0.7152 + data[i + 2] * 0.0722 + 0.5) |
3651 0;
3652 data[i] = data[i + 1] = data[i + 2] = gray;
3653 }
3654 groupCtx.putImageData(imageData, 0, 0);
3655 }
3656
3657 #destroyKnockoutPools(groupMeta) {
3658 if (!groupMeta) {
3659 return;
3660 }
3661 if (groupMeta.knockoutMaskEntry) {
3662 this.canvasFactory.destroy(groupMeta.knockoutMaskEntry);
3663 groupMeta.knockoutMaskEntry = null;
3664 }
3665 if (groupMeta.knockoutTempEntry) {
3666 this.canvasFactory.destroy(groupMeta.knockoutTempEntry);
3667 groupMeta.knockoutTempEntry = null;
3668 }
3669 if (groupMeta.knockoutBackdropEntry) {
3670 this.canvasFactory.destroy(groupMeta.knockoutBackdropEntry);
3671 groupMeta.knockoutBackdropEntry = null;
3672 }
3673 }
3674
3675 beginAnnotation(
3676 opIdx,
3677 id,
3678 rect,
3679 transform,
3680 matrix,
3681 hasOwnCanvas,
3682 canvasName
3683 ) {
3684 // The annotations are drawn just after the page content.
3685 // The page content drawing can potentially have set a transform,
3686 // a clipping path, whatever...
3687 // So in order to have something clean, we restore the initial state.
3688 this.#restoreInitialState();
3689 resetCtxToDefault(this.ctx);
3690
3691 this.ctx.save();
3692 this.save(opIdx);
3693
3694 if (this.baseTransform) {
3695 this.ctx.setTransform(...this.baseTransform);
3696 }
3697
3698 if (rect) {
3699 const width = rect[2] - rect[0];
3700 const height = rect[3] - rect[1];
3701
3702 if (hasOwnCanvas && this.annotationCanvasMap) {
3703 transform = transform.slice();
3704 transform[4] -= rect[0];
3705 transform[5] -= rect[1];
3706
3707 Util.singularValueDecompose2dScale(getCurrentTransform(this.ctx), XY);
3708 const { viewportScale } = this;
3709 const canvasWidth = Math.ceil(
3710 width * this.outputScaleX * viewportScale
3711 );
3712 const canvasHeight = Math.ceil(
3713 height * this.outputScaleY * viewportScale
3714 );
3715
3716 this.annotationCanvas = this.canvasFactory.create(
3717 canvasWidth,
3718 canvasHeight
3719 );
3720 const { canvas, context } = this.annotationCanvas;
3721 if (canvasName) {
3722 const canvases = this.annotationCanvasMap.getOrInsertComputed(
3723 id,
3724 makeArr
3725 );
3726 canvas.setAttribute("data-canvas-name", canvasName);
3727 // Replace any same-named canvas from a previous render so stale
3728 // low-resolution canvases don't pile up across zooms.
3729 const index = canvases.findIndex(
3730 c => c.getAttribute("data-canvas-name") === canvasName
3731 );
3732 if (index === -1) {
3733 canvases.push(canvas);
3734 } else {
3735 canvases[index] = canvas;
3736 }
3737 } else {
3738 this.annotationCanvasMap.set(id, canvas);
3739 }
3740 this.annotationCanvas.savedCtx = this.ctx;
3741 this.ctx = context;
3742 this.ctx.save();
3743 this.ctx.setTransform(XY[0], 0, 0, -XY[1], 0, height * XY[1]);
3744
3745 resetCtxToDefault(this.ctx);
3746 } else {
3747 resetCtxToDefault(this.ctx);
3748
3749 // Consume a potential path before clipping.
3750 this.endPath(opIdx);
3751
3752 const clip = new Path2D();
3753 clip.rect(rect[0], rect[1], width, height);
3754 this.ctx.clip(clip);
3755 }
3756 }
3757
3758 this.current = new CanvasExtraState(
3759 this.ctx.canvas.width,
3760 this.ctx.canvas.height
3761 );
3762
3763 this.baseTransformStack.push(this.baseTransform);
3764 this.transform(opIdx, ...transform);
3765 this.transform(opIdx, ...matrix);
3766 this.baseTransform = getCurrentTransform(this.ctx);
3767 }
3768
3769 endAnnotation(opIdx) {
3770 if (this.annotationCanvas) {
3771 this.ctx.restore();
3772 this.#drawFilter();
3773
3774 this.ctx = this.annotationCanvas.savedCtx;
3775 delete this.annotationCanvas.savedCtx;
3776 delete this.annotationCanvas;
3777 }
3778 this.baseTransform = this.baseTransformStack.pop();
3779 }
3780
3781 paintImageMaskXObject(opIdx, img) {
3782 if (!this.contentVisible) {
3783 return;
3784 }
3785
3786 const count = img.count;
3787 img = this.getObject(opIdx, img.data, img);
3788 img.count = count;
3789
3790 const started = this.#beginKnockoutElement(this.current.fillAlpha);
3791 const ctx = this.ctx;
3792 const mask = this._createMaskCanvas(opIdx, img);
3793 const maskCanvas = mask.canvas;
3794
3795 ctx.save();
3796 // The mask is drawn with the transform applied. Reset the current
3797 // transform to draw to the identity.
3798 ctx.setTransform(1, 0, 0, 1, 0, 0);
3799 ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY);
3800 this.dependencyTracker
3801 ?.resetBBox(opIdx)
3802 .recordBBox(
3803 opIdx,
3804 this.ctx,
3805 mask.offsetX,
3806 mask.offsetX + maskCanvas.width,
3807 mask.offsetY,
3808 mask.offsetY + maskCanvas.height
3809 )
3810 .recordOperation(opIdx);
3811 ctx.restore();
3812 if (mask.canvasEntry) {
3813 this.canvasFactory.destroy(mask.canvasEntry);
3814 }
3815 this.compose();
3816 this.#endKnockoutElement(started);
3817 }
3818
3819 paintImageMaskXObjectRepeat(
3820 opIdx,
3821 img,
3822 scaleX,
3823 skewX = 0,
3824 skewY = 0,
3825 scaleY,
3826 positions
3827 ) {
3828 if (!this.contentVisible) {
3829 return;
3830 }
3831
3832 img = this.getObject(opIdx, img.data, img);
3833
3834 const started = this.#beginKnockoutElement(this.current.fillAlpha);
3835 const ctx = this.ctx;
3836 ctx.save();
3837 const currentTransform = getCurrentTransform(ctx);
3838 ctx.transform(scaleX, skewX, skewY, scaleY, 0, 0);
3839 const mask = this._createMaskCanvas(opIdx, img);
3840
3841 ctx.setTransform(
3842 1,
3843 0,
3844 0,
3845 1,
3846 mask.offsetX - currentTransform[4],
3847 mask.offsetY - currentTransform[5]
3848 );
3849 this.dependencyTracker?.resetBBox(opIdx);
3850 for (let i = 0, ii = positions.length; i < ii; i += 2) {
3851 const trans = Util.transform(currentTransform, [
3852 scaleX,
3853 skewX,
3854 skewY,
3855 scaleY,
3856 positions[i],
3857 positions[i + 1],
3858 ]);
3859
3860 // Here we want to apply the transform at the origin,
3861 // hence no additional computation is necessary.
3862 ctx.drawImage(mask.canvas, trans[4], trans[5]);
3863 this.dependencyTracker?.recordBBox(
3864 opIdx,
3865 this.ctx,
3866 trans[4],
3867 trans[4] + mask.canvas.width,
3868 trans[5],
3869 trans[5] + mask.canvas.height
3870 );
3871 }
3872 ctx.restore();
3873 if (mask.canvasEntry) {
3874 this.canvasFactory.destroy(mask.canvasEntry);
3875 }
3876 this.compose();
3877
3878 this.dependencyTracker?.recordOperation(opIdx);
3879 this.#endKnockoutElement(started);
3880 }
3881
3882 paintImageMaskXObjectGroup(opIdx, images) {
3883 if (!this.contentVisible) {
3884 return;
3885 }
3886 const started = this.#beginKnockoutElement(this.current.fillAlpha);
3887 const ctx = this.ctx;
3888
3889 const fillColor = this.current.fillColor;
3890 const isPatternFill = this.current.patternFill;
3891
3892 this.dependencyTracker
3893 ?.resetBBox(opIdx)
3894 .recordDependencies(opIdx, Dependencies.transformAndFill);
3895
3896 for (const image of images) {
3897 const { data, width, height, transform } = image;
3898
3899 const maskCanvas = this.canvasFactory.create(width, height);
3900 const maskCtx = maskCanvas.context;
3901 maskCtx.save();
3902
3903 const img = this.getObject(opIdx, data, image);
3904 putBinaryImageMask(maskCtx, img);
3905
3906 maskCtx.globalCompositeOperation = "source-in";
3907
3908 maskCtx.fillStyle = isPatternFill
3909 ? fillColor.getPattern(
3910 maskCtx,
3911 this,
3912 getCurrentTransformInverse(ctx),
3913 PathType.FILL,
3914 opIdx
3915 )
3916 : fillColor;
3917 maskCtx.fillRect(0, 0, width, height);
3918
3919 maskCtx.restore();
3920
3921 ctx.save();
3922 ctx.transform(...transform);
3923 ctx.scale(1, -1);
3924 drawImageAtIntegerCoords(
3925 ctx,
3926 maskCanvas.canvas,
3927 0,
3928 0,
3929 width,
3930 height,
3931 0,
3932 -1,
3933 1,
3934 1
3935 );
3936 this.canvasFactory.destroy(maskCanvas);
3937
3938 this.dependencyTracker?.recordBBox(opIdx, ctx, 0, width, 0, height);
3939 ctx.restore();
3940 }
3941 this.compose();
3942 this.dependencyTracker?.recordOperation(opIdx);
3943 this.#endKnockoutElement(started);
3944 }
3945
3946 paintImageXObject(opIdx, objId) {
3947 if (!this.contentVisible) {
3948 return;
3949 }
3950 const imgData = this.getObject(opIdx, objId);
3951 if (!imgData) {
3952 warn("Dependent image isn't ready yet");
3953 return;
3954 }
3955
3956 this.paintInlineImageXObject(opIdx, imgData);
3957 }
3958
3959 paintImageXObjectRepeat(opIdx, objId, scaleX, scaleY, positions) {
3960 if (!this.contentVisible) {
3961 return;
3962 }
3963 const imgData = this.getObject(opIdx, objId);
3964 if (!imgData) {
3965 warn("Dependent image isn't ready yet");
3966 return;
3967 }
3968
3969 const width = imgData.width;
3970 const height = imgData.height;
3971 const map = [];
3972 for (let i = 0, ii = positions.length; i < ii; i += 2) {
3973 map.push({
3974 transform: [scaleX, 0, 0, scaleY, positions[i], positions[i + 1]],
3975 x: 0,
3976 y: 0,
3977 w: width,
3978 h: height,
3979 });
3980 }
3981 this.paintInlineImageXObjectGroup(opIdx, imgData, map);
3982 }
3983
3984 applyTransferMapsToCanvas(ctx) {
3985 if (this.current.transferMaps !== "none") {
3986 ctx.filter = this.current.transferMaps;
3987 ctx.drawImage(ctx.canvas, 0, 0);
3988 ctx.filter = "none";
3989 }
3990 return ctx.canvas;
3991 }
3992
3993 applyTransferMapsToBitmap(imgData) {
3994 if (this.current.transferMaps === "none") {
3995 return { img: imgData.bitmap, canvasEntry: null };
3996 }
3997 const { bitmap, width, height } = imgData;
3998 const tmpCanvas = this.canvasFactory.create(width, height);
3999 const tmpCtx = tmpCanvas.context;
4000 tmpCtx.filter = this.current.transferMaps;
4001 tmpCtx.drawImage(bitmap, 0, 0);
4002 tmpCtx.filter = "none";
4003
4004 return { img: tmpCanvas.canvas, canvasEntry: tmpCanvas };
4005 }
4006
4007 paintInlineImageXObject(opIdx, imgData) {
4008 if (!this.contentVisible) {
4009 return;
4010 }
4011 const width = imgData.width;
4012 const height = imgData.height;
4013 const started = this.#beginKnockoutElement(this.current.fillAlpha);
4014 const ctx = this.ctx;
4015
4016 this.save(opIdx);
4017
4018 // The filter, if any, will be applied in applyTransferMapsToBitmap.
4019 // It must be applied to the image before rescaling else some artifacts
4020 // could appear.
4021 // The final restore will reset it to its value.
4022 const { filter } = ctx;
4023 if (filter !== "none" && filter !== "") {
4024 ctx.filter = "none";
4025 }
4026
4027 // scale the image to the unit square
4028 ctx.scale(1 / width, -1 / height);
4029
4030 let imgToPaint;
4031 let inlineImgCanvas = null;
4032 if (imgData.bitmap) {
4033 const result = this.applyTransferMapsToBitmap(imgData);
4034 imgToPaint = result.img;
4035 inlineImgCanvas = result.canvasEntry;
4036 } else {
4037 const tmpCanvas = this.canvasFactory.create(width, height);
4038 putBinaryImageData(tmpCanvas.context, imgData);
4039 imgToPaint = this.applyTransferMapsToCanvas(tmpCanvas.context);
4040 inlineImgCanvas = tmpCanvas;
4041 }
4042
4043 const scaled = this._scaleImage(
4044 imgToPaint,
4045 getCurrentTransformInverse(ctx)
4046 );
4047 ctx.imageSmoothingEnabled = getImageSmoothingEnabled(
4048 getCurrentTransform(ctx),
4049 imgData.interpolate
4050 );
4051
4052 if (this.dependencyTracker) {
4053 this.dependencyTracker
4054 .resetBBox(opIdx)
4055 .recordBBox(opIdx, ctx, 0, width, -height, 0)
4056 .recordDependencies(opIdx, Dependencies.imageXObject)
4057 .recordOperation(opIdx);
4058 this.imagesTracker?.record(
4059 ctx,
4060 width,
4061 height,
4062 this.dependencyTracker.clipBox
4063 );
4064 }
4065
4066 drawImageAtIntegerCoords(
4067 ctx,
4068 scaled.img,
4069 0,
4070 0,
4071 scaled.paintWidth,
4072 scaled.paintHeight,
4073 0,
4074 -height,
4075 width,
4076 height
4077 );
4078 if (scaled.tmpCanvas) {
4079 this.canvasFactory.destroy(scaled.tmpCanvas);
4080 }
4081 if (inlineImgCanvas) {
4082 this.canvasFactory.destroy(inlineImgCanvas);
4083 }
4084 this.compose();
4085 this.restore(opIdx);
4086 this.#endKnockoutElement(started);
4087 }
4088
4089 paintInlineImageXObjectGroup(opIdx, imgData, map) {
4090 if (!this.contentVisible) {
4091 return;
4092 }
4093 const started = this.#beginKnockoutElement(this.current.fillAlpha);
4094 const ctx = this.ctx;
4095 let imgToPaint;
4096 let inlineImgCanvas = null;
4097 if (imgData.bitmap) {
4098 imgToPaint = imgData.bitmap;
4099 } else {
4100 const w = imgData.width;
4101 const h = imgData.height;
4102
4103 const tmpCanvas = this.canvasFactory.create(w, h);
4104 putBinaryImageData(tmpCanvas.context, imgData);
4105 imgToPaint = this.applyTransferMapsToCanvas(tmpCanvas.context);
4106 inlineImgCanvas = tmpCanvas;
4107 }
4108
4109 this.dependencyTracker?.resetBBox(opIdx);
4110
4111 for (const entry of map) {
4112 ctx.save();
4113 ctx.transform(...entry.transform);
4114 ctx.scale(1, -1);
4115 drawImageAtIntegerCoords(
4116 ctx,
4117 imgToPaint,
4118 entry.x,
4119 entry.y,
4120 entry.w,
4121 entry.h,
4122 0,
4123 -1,
4124 1,
4125 1
4126 );
4127 this.dependencyTracker?.recordBBox(opIdx, ctx, 0, 1, -1, 0);
4128 ctx.restore();
4129 }
4130 if (inlineImgCanvas) {
4131 this.canvasFactory.destroy(inlineImgCanvas);
4132 }
4133 this.dependencyTracker?.recordOperation(opIdx);
4134 this.compose();
4135 this.#endKnockoutElement(started);
4136 }
4137
4138 paintSolidColorImageMask(opIdx) {
4139 if (!this.contentVisible) {
4140 return;
4141 }
4142 const started = this.#beginKnockoutElement(this.current.fillAlpha);
4143 this.dependencyTracker
4144 ?.resetBBox(opIdx)
4145 .recordBBox(opIdx, this.ctx, 0, 1, 0, 1)
4146 .recordDependencies(opIdx, Dependencies.fill)
4147 .recordOperation(opIdx);
4148 this.ctx.fillRect(0, 0, 1, 1);
4149 this.compose();
4150 this.#endKnockoutElement(started);
4151 }
4152
4153 // Marked content
4154
4155 markPoint(opIdx, tag) {
4156 // TODO Marked content.
4157 }
4158
4159 markPointProps(opIdx, tag, properties) {
4160 // TODO Marked content.
4161 }
4162
4163 beginMarkedContent(opIdx, tag) {
4164 this.dependencyTracker?.beginMarkedContent(opIdx);
4165 this.markedContentStack.push({
4166 visible: true,
4167 });
4168 }
4169
4170 beginMarkedContentProps(opIdx, tag, properties) {
4171 this.dependencyTracker?.beginMarkedContent(opIdx);
4172 if (tag === "OC") {
4173 this.markedContentStack.push({
4174 visible: this.optionalContentConfig.isVisible(properties),
4175 });
4176 } else {
4177 this.markedContentStack.push({
4178 visible: true,
4179 });
4180 }
4181 this.contentVisible = this.isContentVisible();
4182 }
4183
4184 endMarkedContent(opIdx) {
4185 this.dependencyTracker?.endMarkedContent(opIdx);
4186 this.markedContentStack.pop();
4187 this.contentVisible = this.isContentVisible();
4188 }
4189
4190 // Compatibility
4191
4192 beginCompat(opIdx) {
4193 // TODO ignore undefined operators (should we do that anyway?)
4194 }
4195
4196 endCompat(opIdx) {
4197 // TODO stop ignoring undefined operators
4198 }
4199
4200 // Helper functions
4201
4202 consumePath(opIdx, path, clipBox) {
4203 const isEmpty = this.current.isEmptyClip();
4204 if (this.pendingClip) {
4205 this.current.updateClipFromPath();
4206 }
4207 if (!this.pendingClip) {
4208 this.compose(clipBox);
4209 }
4210 const ctx = this.ctx;
4211 if (this.pendingClip) {
4212 if (!isEmpty) {
4213 if (this.pendingClip === EO_CLIP) {
4214 ctx.clip(path, "evenodd");
4215 } else {
4216 ctx.clip(path);
4217 }
4218 }
4219 this.pendingClip = null;
4220 this.dependencyTracker
4221 ?.bboxToClipBoxDropOperation(opIdx)
4222 .recordFutureForcedDependency("clipPath", opIdx);
4223 } else {
4224 this.dependencyTracker?.recordOperation(opIdx);
4225 }
4226
4227 this.current.startNewPathAndClipBox(this.current.clipBox);
4228 }
4229
4230 getSinglePixelWidth() {
4231 if (!this._cachedGetSinglePixelWidth) {
4232 const m = getCurrentTransform(this.ctx);
4233 if (m[1] === 0 && m[2] === 0) {
4234 // Fast path
4235 this._cachedGetSinglePixelWidth =
4236 1 / Math.min(Math.abs(m[0]), Math.abs(m[3]));
4237 } else {
4238 const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
4239 const normX = Math.hypot(m[0], m[2]);
4240 const normY = Math.hypot(m[1], m[3]);
4241 this._cachedGetSinglePixelWidth = Math.max(normX, normY) / absDet;
4242 }
4243 }
4244 return this._cachedGetSinglePixelWidth;
4245 }
4246
4247 getScaleForStroking() {
4248 // A pixel has thicknessX = thicknessY = 1;
4249 // A transformed pixel is a parallelogram and the thicknesses
4250 // corresponds to the heights.
4251 // The goal of this function is to rescale before setting the
4252 // lineWidth in order to have both thicknesses greater or equal
4253 // to 1 after transform.
4254 if (this._cachedScaleForStroking[0] === -1) {
4255 const { lineWidth } = this.current;
4256 const { a, b, c, d } = this.ctx.getTransform();
4257 let scaleX, scaleY;
4258
4259 if (b === 0 && c === 0) {
4260 // Fast path
4261 const normX = Math.abs(a);
4262 const normY = Math.abs(d);
4263 if (normX === normY) {
4264 if (lineWidth === 0) {
4265 scaleX = scaleY = 1 / normX;
4266 } else {
4267 const scaledLineWidth = normX * lineWidth;
4268 scaleX = scaleY = scaledLineWidth < 1 ? 1 / scaledLineWidth : 1;
4269 }
4270 } else if (lineWidth === 0) {
4271 scaleX = 1 / normX;
4272 scaleY = 1 / normY;
4273 } else {
4274 const scaledXLineWidth = normX * lineWidth;
4275 const scaledYLineWidth = normY * lineWidth;
4276 scaleX = scaledXLineWidth < 1 ? 1 / scaledXLineWidth : 1;
4277 scaleY = scaledYLineWidth < 1 ? 1 / scaledYLineWidth : 1;
4278 }
4279 } else {
4280 // A pixel (base (x, y)) is transformed by M into a parallelogram:
4281 // - its area is |det(M)|;
4282 // - heightY (orthogonal to Mx) has a length: |det(M)| / norm(Mx);
4283 // - heightX (orthogonal to My) has a length: |det(M)| / norm(My).
4284 // heightX and heightY are the thicknesses of the transformed pixel
4285 // and they must be both greater or equal to 1.
4286 const absDet = Math.abs(a * d - b * c);
4287 const normX = Math.hypot(a, b);
4288 const normY = Math.hypot(c, d);
4289 if (lineWidth === 0) {
4290 scaleX = normY / absDet;
4291 scaleY = normX / absDet;
4292 } else {
4293 const baseArea = lineWidth * absDet;
4294 scaleX = normY > baseArea ? normY / baseArea : 1;
4295 scaleY = normX > baseArea ? normX / baseArea : 1;
4296 }
4297 }
4298 this._cachedScaleForStroking[0] = scaleX;
4299 this._cachedScaleForStroking[1] = scaleY;
4300 }
4301 return this._cachedScaleForStroking;
4302 }
4303
4304 // Rescale before stroking in order to have a final lineWidth
4305 // with both thicknesses greater or equal to 1.
4306 rescaleAndStroke(path, saveRestore) {
4307 const {
4308 ctx,
4309 current: { lineWidth },
4310 } = this;
4311 const [scaleX, scaleY] = this.getScaleForStroking();
4312
4313 if (scaleX === scaleY) {
4314 ctx.lineWidth = (lineWidth || 1) * scaleX;
4315 ctx.stroke(path);
4316 return;
4317 }
4318 const SCALE_MATRIX = (CanvasGraphics.#SCALE_MATRIX ??= new DOMMatrix());
4319
4320 const dashes = ctx.getLineDash();
4321 if (saveRestore) {
4322 ctx.save();
4323 }
4324
4325 ctx.scale(scaleX, scaleY);
4326 SCALE_MATRIX.a = 1 / scaleX;
4327 SCALE_MATRIX.d = 1 / scaleY;
4328 const newPath = new Path2D();
4329 newPath.addPath(path, SCALE_MATRIX);
4330
4331 // How the dashed line is rendered depends on the current transform...
4332 // so we added a rescale to handle too thin lines and consequently
4333 // the way the line is dashed will be modified.
4334 // If scaleX === scaleY, the dashed lines will be rendered correctly
4335 // else we'll have some bugs (but only with too thin lines).
4336 // Here we take the max... why not taking the min... or something else.
4337 // Anyway, as said it's buggy when scaleX !== scaleY.
4338 if (dashes.length > 0) {
4339 const scale = Math.max(scaleX, scaleY);
4340 ctx.setLineDash(dashes.map(x => x / scale));
4341 ctx.lineDashOffset /= scale;
4342 }
4343
4344 ctx.lineWidth = lineWidth || 1;
4345 ctx.stroke(newPath);
4346
4347 if (saveRestore) {
4348 ctx.restore();
4349 }
4350 }
4351
4352 isContentVisible() {
4353 for (let i = this.markedContentStack.length - 1; i >= 0; i--) {
4354 if (!this.markedContentStack[i].visible) {
4355 return false;
4356 }
4357 }
4358 return true;
4359 }
4360}
4361
4362for (const op in OPS) {
4363 if (CanvasGraphics.prototype[op] !== undefined) {
4364 CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
4365 }
4366}
4367
4368export { CanvasGraphics };