/* Copyright 2012 Mozilla Foundation
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {
  FeatureTest,
  FONT_IDENTITY_MATRIX,
  IDENTITY_MATRIX,
  ImageKind,
  info,
  OPS,
  shadow,
  TextRenderingMode,
  unreachable,
  Util,
  warn,
} from "../shared/util.js";
import { getRGB, PixelsPerInch } from "./display_utils.js";
import {
  getShadingPattern,
  PathType,
  TilingPattern,
} from "./pattern_helper.js";
import { applyMaskImageData } from "../shared/image_utils.js";
import { isNodeJS } from "../shared/is_node.js";

// <canvas> contexts store most of the state we need natively.
// However, PDF needs a bit more state, which we store here.
// Minimal font size that would be used during canvas fillText operations.
const MIN_FONT_SIZE = 16;
// Maximum font size that would be used during canvas fillText operations.
const MAX_FONT_SIZE = 100;
const MAX_GROUP_SIZE = 4096;

// Defines the time the `executeOperatorList`-method is going to be executing
// before it stops and schedules a continue of execution.
const EXECUTION_TIME = 15; // ms
// Defines the number of steps before checking the execution time.
const EXECUTION_STEPS = 10;

const COMPILE_TYPE3_GLYPHS = true;
const MAX_SIZE_TO_COMPILE = 1000;

const FULL_CHUNK_HEIGHT = 16;

// Because of https://bugs.chromium.org/p/chromium/issues/detail?id=1170396
// some curves aren't rendered correctly.
// Multiplying lineWidth by this factor should help to have "correct"
// rendering with no artifacts.
// Once the bug is fixed upstream, we can remove this constant and its use.
const LINEWIDTH_SCALE_FACTOR = 1.000001;

/**
 * Overrides certain methods on a 2d ctx so that when they are called they
 * will also call the same method on the destCtx. The methods that are
 * overridden are all the transformation state modifiers, path creation, and
 * save/restore. We only forward these specific methods because they are the
 * only state modifiers that we cannot copy over when we switch contexts.
 *
 * To remove mirroring call `ctx._removeMirroring()`.
 *
 * @param {Object} ctx - The 2d canvas context that will duplicate its calls on
 *   the destCtx.
 * @param {Object} destCtx - The 2d canvas context that will receive the
 *   forwarded calls.
 */
function mirrorContextOperations(ctx, destCtx) {
  if (ctx._removeMirroring) {
    throw new Error("Context is already forwarding operations.");
  }
  ctx.__originalSave = ctx.save;
  ctx.__originalRestore = ctx.restore;
  ctx.__originalRotate = ctx.rotate;
  ctx.__originalScale = ctx.scale;
  ctx.__originalTranslate = ctx.translate;
  ctx.__originalTransform = ctx.transform;
  ctx.__originalSetTransform = ctx.setTransform;
  ctx.__originalResetTransform = ctx.resetTransform;
  ctx.__originalClip = ctx.clip;
  ctx.__originalMoveTo = ctx.moveTo;
  ctx.__originalLineTo = ctx.lineTo;
  ctx.__originalBezierCurveTo = ctx.bezierCurveTo;
  ctx.__originalRect = ctx.rect;
  ctx.__originalClosePath = ctx.closePath;
  ctx.__originalBeginPath = ctx.beginPath;

  ctx._removeMirroring = () => {
    ctx.save = ctx.__originalSave;
    ctx.restore = ctx.__originalRestore;
    ctx.rotate = ctx.__originalRotate;
    ctx.scale = ctx.__originalScale;
    ctx.translate = ctx.__originalTranslate;
    ctx.transform = ctx.__originalTransform;
    ctx.setTransform = ctx.__originalSetTransform;
    ctx.resetTransform = ctx.__originalResetTransform;

    ctx.clip = ctx.__originalClip;
    ctx.moveTo = ctx.__originalMoveTo;
    ctx.lineTo = ctx.__originalLineTo;
    ctx.bezierCurveTo = ctx.__originalBezierCurveTo;
    ctx.rect = ctx.__originalRect;
    ctx.closePath = ctx.__originalClosePath;
    ctx.beginPath = ctx.__originalBeginPath;
    delete ctx._removeMirroring;
  };

  ctx.save = function ctxSave() {
    destCtx.save();
    this.__originalSave();
  };

  ctx.restore = function ctxRestore() {
    destCtx.restore();
    this.__originalRestore();
  };

  ctx.translate = function ctxTranslate(x, y) {
    destCtx.translate(x, y);
    this.__originalTranslate(x, y);
  };

  ctx.scale = function ctxScale(x, y) {
    destCtx.scale(x, y);
    this.__originalScale(x, y);
  };

  ctx.transform = function ctxTransform(a, b, c, d, e, f) {
    destCtx.transform(a, b, c, d, e, f);
    this.__originalTransform(a, b, c, d, e, f);
  };

  ctx.setTransform = function ctxSetTransform(a, b, c, d, e, f) {
    destCtx.setTransform(a, b, c, d, e, f);
    this.__originalSetTransform(a, b, c, d, e, f);
  };

  ctx.resetTransform = function ctxResetTransform() {
    destCtx.resetTransform();
    this.__originalResetTransform();
  };

  ctx.rotate = function ctxRotate(angle) {
    destCtx.rotate(angle);
    this.__originalRotate(angle);
  };

  ctx.clip = function ctxRotate(rule) {
    destCtx.clip(rule);
    this.__originalClip(rule);
  };

  ctx.moveTo = function (x, y) {
    destCtx.moveTo(x, y);
    this.__originalMoveTo(x, y);
  };

  ctx.lineTo = function (x, y) {
    destCtx.lineTo(x, y);
    this.__originalLineTo(x, y);
  };

  ctx.bezierCurveTo = function (cp1x, cp1y, cp2x, cp2y, x, y) {
    destCtx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
    this.__originalBezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
  };

  ctx.rect = function (x, y, width, height) {
    destCtx.rect(x, y, width, height);
    this.__originalRect(x, y, width, height);
  };

  ctx.closePath = function () {
    destCtx.closePath();
    this.__originalClosePath();
  };

  ctx.beginPath = function () {
    destCtx.beginPath();
    this.__originalBeginPath();
  };
}

function addContextCurrentTransform(ctx) {
  if (ctx._transformStack) {
    // Reset the transform stack.
    ctx._transformStack = [];
  }
  // If the context doesn't expose a `mozCurrentTransform`, add a JS based one.
  if (ctx.mozCurrentTransform) {
    return;
  }
  ctx._originalSave = ctx.save;
  ctx._originalRestore = ctx.restore;
  ctx._originalRotate = ctx.rotate;
  ctx._originalScale = ctx.scale;
  ctx._originalTranslate = ctx.translate;
  ctx._originalTransform = ctx.transform;
  ctx._originalSetTransform = ctx.setTransform;
  ctx._originalResetTransform = ctx.resetTransform;

  ctx._transformMatrix = ctx._transformMatrix || [1, 0, 0, 1, 0, 0];
  ctx._transformStack = [];

  try {
    // The call to getOwnPropertyDescriptor throws an exception in Node.js:
    // "TypeError: Method lineWidth called on incompatible receiver
    //  #<CanvasRenderingContext2D>".
    const desc = Object.getOwnPropertyDescriptor(
      Object.getPrototypeOf(ctx),
      "lineWidth"
    );

    ctx._setLineWidth = desc.set;
    ctx._getLineWidth = desc.get;

    Object.defineProperty(ctx, "lineWidth", {
      set: function setLineWidth(width) {
        this._setLineWidth(width * LINEWIDTH_SCALE_FACTOR);
      },
      get: function getLineWidth() {
        return this._getLineWidth();
      },
    });
  } catch (_) {}

  Object.defineProperty(ctx, "mozCurrentTransform", {
    get: function getCurrentTransform() {
      return this._transformMatrix;
    },
  });

  Object.defineProperty(ctx, "mozCurrentTransformInverse", {
    get: function getCurrentTransformInverse() {
      // Calculation done using WolframAlpha:
      // http://www.wolframalpha.com/input/?
      //   i=Inverse+{{a%2C+c%2C+e}%2C+{b%2C+d%2C+f}%2C+{0%2C+0%2C+1}}

      const [a, b, c, d, e, f] = this._transformMatrix;
      const ad_bc = a * d - b * c;
      const bc_ad = b * c - a * d;

      return [
        d / ad_bc,
        b / bc_ad,
        c / bc_ad,
        a / ad_bc,
        (d * e - c * f) / bc_ad,
        (b * e - a * f) / ad_bc,
      ];
    },
  });

  ctx.save = function ctxSave() {
    const old = this._transformMatrix;
    this._transformStack.push(old);
    this._transformMatrix = old.slice(0, 6);

    this._originalSave();
  };

  ctx.restore = function ctxRestore() {
    if (this._transformStack.length === 0) {
      warn("Tried to restore a ctx when the stack was already empty.");
    }
    const prev = this._transformStack.pop();
    if (prev) {
      this._transformMatrix = prev;
      this._originalRestore();
    }
  };

  ctx.translate = function ctxTranslate(x, y) {
    const m = this._transformMatrix;
    m[4] = m[0] * x + m[2] * y + m[4];
    m[5] = m[1] * x + m[3] * y + m[5];

    this._originalTranslate(x, y);
  };

  ctx.scale = function ctxScale(x, y) {
    const m = this._transformMatrix;
    m[0] *= x;
    m[1] *= x;
    m[2] *= y;
    m[3] *= y;

    this._originalScale(x, y);
  };

  ctx.transform = function ctxTransform(a, b, c, d, e, f) {
    const m = this._transformMatrix;
    this._transformMatrix = [
      m[0] * a + m[2] * b,
      m[1] * a + m[3] * b,
      m[0] * c + m[2] * d,
      m[1] * c + m[3] * d,
      m[0] * e + m[2] * f + m[4],
      m[1] * e + m[3] * f + m[5],
    ];

    ctx._originalTransform(a, b, c, d, e, f);
  };

  ctx.setTransform = function ctxSetTransform(a, b, c, d, e, f) {
    this._transformMatrix = [a, b, c, d, e, f];

    ctx._originalSetTransform(a, b, c, d, e, f);
  };

  ctx.resetTransform = function ctxResetTransform() {
    this._transformMatrix = [1, 0, 0, 1, 0, 0];

    ctx._originalResetTransform();
  };

  ctx.rotate = function ctxRotate(angle) {
    const cosValue = Math.cos(angle);
    const sinValue = Math.sin(angle);

    const m = this._transformMatrix;
    this._transformMatrix = [
      m[0] * cosValue + m[2] * sinValue,
      m[1] * cosValue + m[3] * sinValue,
      m[0] * -sinValue + m[2] * cosValue,
      m[1] * -sinValue + m[3] * cosValue,
      m[4],
      m[5],
    ];

    this._originalRotate(angle);
  };
}

class CachedCanvases {
  constructor(canvasFactory) {
    this.canvasFactory = canvasFactory;
    this.cache = Object.create(null);
  }

  getCanvas(id, width, height, trackTransform) {
    let canvasEntry;
    if (this.cache[id] !== undefined) {
      canvasEntry = this.cache[id];
      this.canvasFactory.reset(canvasEntry, width, height);
      // reset canvas transform for emulated mozCurrentTransform, if needed
      canvasEntry.context.setTransform(1, 0, 0, 1, 0, 0);
    } else {
      canvasEntry = this.canvasFactory.create(width, height);
      this.cache[id] = canvasEntry;
    }
    if (trackTransform) {
      addContextCurrentTransform(canvasEntry.context);
    }
    return canvasEntry;
  }

  delete(id) {
    delete this.cache[id];
  }

  clear() {
    for (const id in this.cache) {
      const canvasEntry = this.cache[id];
      this.canvasFactory.destroy(canvasEntry);
      delete this.cache[id];
    }
  }
}

function drawImageAtIntegerCoords(
  ctx,
  srcImg,
  srcX,
  srcY,
  srcW,
  srcH,
  destX,
  destY,
  destW,
  destH
) {
  const [a, b, c, d, tx, ty] = ctx.mozCurrentTransform;
  if (b === 0 && c === 0) {
    // top-left corner is at (X, Y) and
    // bottom-right one is at (X + width, Y + height).

    // If leftX is 4.321 then it's rounded to 4.
    // If width is 10.432 then it's rounded to 11 because
    // rightX = leftX + width = 14.753 which is rounded to 15
    // so after rounding the total width is 11 (15 - 4).
    // It's why we can't just floor/ceil uniformly, it just depends
    // on the values we've.

    const tlX = destX * a + tx;
    const rTlX = Math.round(tlX);
    const tlY = destY * d + ty;
    const rTlY = Math.round(tlY);
    const brX = (destX + destW) * a + tx;

    // Some pdf contains images with 1x1 images so in case of 0-width after
    // scaling we must fallback on 1 to be sure there is something.
    const rWidth = Math.abs(Math.round(brX) - rTlX) || 1;
    const brY = (destY + destH) * d + ty;
    const rHeight = Math.abs(Math.round(brY) - rTlY) || 1;

    // We must apply a transformation in order to apply it on the image itself.
    // For example if a == 1 && d == -1, it means that the image itself is
    // mirrored w.r.t. the x-axis.
    ctx.setTransform(Math.sign(a), 0, 0, Math.sign(d), rTlX, rTlY);
    ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, 0, 0, rWidth, rHeight);
    ctx.setTransform(a, b, c, d, tx, ty);

    return [rWidth, rHeight];
  }

  if (a === 0 && d === 0) {
    // This path is taken in issue9462.pdf (page 3).
    const tlX = destY * c + tx;
    const rTlX = Math.round(tlX);
    const tlY = destX * b + ty;
    const rTlY = Math.round(tlY);
    const brX = (destY + destH) * c + tx;
    const rWidth = Math.abs(Math.round(brX) - rTlX) || 1;
    const brY = (destX + destW) * b + ty;
    const rHeight = Math.abs(Math.round(brY) - rTlY) || 1;

    ctx.setTransform(0, Math.sign(b), Math.sign(c), 0, rTlX, rTlY);
    ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, 0, 0, rHeight, rWidth);
    ctx.setTransform(a, b, c, d, tx, ty);

    return [rHeight, rWidth];
  }

  // Not a scale matrix so let the render handle the case without rounding.
  ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);

  const scaleX = Math.hypot(a, b);
  const scaleY = Math.hypot(c, d);
  return [scaleX * destW, scaleY * destH];
}

function compileType3Glyph(imgData) {
  const { width, height } = imgData;
  if (
    !COMPILE_TYPE3_GLYPHS ||
    width > MAX_SIZE_TO_COMPILE ||
    height > MAX_SIZE_TO_COMPILE
  ) {
    return null;
  }

  const POINT_TO_PROCESS_LIMIT = 1000;
  const POINT_TYPES = new Uint8Array([
    0, 2, 4, 0, 1, 0, 5, 4, 8, 10, 0, 8, 0, 2, 1, 0,
  ]);

  const width1 = width + 1;
  let points = new Uint8Array(width1 * (height + 1));
  let i, j, j0;

  // decodes bit-packed mask data
  const lineSize = (width + 7) & ~7;
  let data = new Uint8Array(lineSize * height),
    pos = 0;
  for (const elem of imgData.data) {
    let mask = 128;
    while (mask > 0) {
      data[pos++] = elem & mask ? 0 : 255;
      mask >>= 1;
    }
  }

  // finding interesting points: every point is located between mask pixels,
  // so there will be points of the (width + 1)x(height + 1) grid. Every point
  // will have flags assigned based on neighboring mask pixels:
  //   4 | 8
  //   --P--
  //   2 | 1
  // We are interested only in points with the flags:
  //   - outside corners: 1, 2, 4, 8;
  //   - inside corners: 7, 11, 13, 14;
  //   - and, intersections: 5, 10.
  let count = 0;
  pos = 0;
  if (data[pos] !== 0) {
    points[0] = 1;
    ++count;
  }
  for (j = 1; j < width; j++) {
    if (data[pos] !== data[pos + 1]) {
      points[j] = data[pos] ? 2 : 1;
      ++count;
    }
    pos++;
  }
  if (data[pos] !== 0) {
    points[j] = 2;
    ++count;
  }
  for (i = 1; i < height; i++) {
    pos = i * lineSize;
    j0 = i * width1;
    if (data[pos - lineSize] !== data[pos]) {
      points[j0] = data[pos] ? 1 : 8;
      ++count;
    }
    // 'sum' is the position of the current pixel configuration in the 'TYPES'
    // array (in order 8-1-2-4, so we can use '>>2' to shift the column).
    let sum = (data[pos] ? 4 : 0) + (data[pos - lineSize] ? 8 : 0);
    for (j = 1; j < width; j++) {
      sum =
        (sum >> 2) +
        (data[pos + 1] ? 4 : 0) +
        (data[pos - lineSize + 1] ? 8 : 0);
      if (POINT_TYPES[sum]) {
        points[j0 + j] = POINT_TYPES[sum];
        ++count;
      }
      pos++;
    }
    if (data[pos - lineSize] !== data[pos]) {
      points[j0 + j] = data[pos] ? 2 : 4;
      ++count;
    }

    if (count > POINT_TO_PROCESS_LIMIT) {
      return null;
    }
  }

  pos = lineSize * (height - 1);
  j0 = i * width1;
  if (data[pos] !== 0) {
    points[j0] = 8;
    ++count;
  }
  for (j = 1; j < width; j++) {
    if (data[pos] !== data[pos + 1]) {
      points[j0 + j] = data[pos] ? 4 : 8;
      ++count;
    }
    pos++;
  }
  if (data[pos] !== 0) {
    points[j0 + j] = 4;
    ++count;
  }
  if (count > POINT_TO_PROCESS_LIMIT) {
    return null;
  }

  // building outlines
  const steps = new Int32Array([0, width1, -1, 0, -width1, 0, 0, 0, 1]);
  let path, outlines, coords;
  if (!isNodeJS) {
    path = new Path2D();
  } else {
    outlines = [];
  }

  for (i = 0; count && i <= height; i++) {
    let p = i * width1;
    const end = p + width;
    while (p < end && !points[p]) {
      p++;
    }
    if (p === end) {
      continue;
    }

    if (path) {
      path.moveTo(p % width1, i);
    } else {
      coords = [p % width1, i];
    }

    const p0 = p;
    let type = points[p];
    do {
      const step = steps[type];
      do {
        p += step;
      } while (!points[p]);

      const pp = points[p];
      if (pp !== 5 && pp !== 10) {
        // set new direction
        type = pp;
        // delete mark
        points[p] = 0;
      } else {
        // type is 5 or 10, ie, a crossing
        // set new direction
        type = pp & ((0x33 * type) >> 4);
        // set new type for "future hit"
        points[p] &= (type >> 2) | (type << 2);
      }

      if (path) {
        path.lineTo(p % width1, (p / width1) | 0);
      } else {
        coords.push(p % width1, (p / width1) | 0);
      }

      if (!points[p]) {
        --count;
      }
    } while (p0 !== p);

    if (!path) {
      outlines.push(coords);
    }
    --i;
  }

  // Immediately release the, potentially large, `Uint8Array`s after parsing.
  data = null;
  points = null;

  const drawOutline = function (c) {
    c.save();
    // the path shall be painted in [0..1]x[0..1] space
    c.scale(1 / width, -1 / height);
    c.translate(0, -height);
    if (path) {
      c.fill(path);
    } else {
      c.beginPath();
      for (const o of outlines) {
        c.moveTo(o[0], o[1]);
        for (let l = 2, ll = o.length; l < ll; l += 2) {
          c.lineTo(o[l], o[l + 1]);
        }
      }
      c.fill();
    }
    c.beginPath();
    c.restore();
  };

  return drawOutline;
}

class CanvasExtraState {
  constructor(width, height) {
    // Are soft masks and alpha values shapes or opacities?
    this.alphaIsShape = false;
    this.fontSize = 0;
    this.fontSizeScale = 1;
    this.textMatrix = IDENTITY_MATRIX;
    this.textMatrixScale = 1;
    this.fontMatrix = FONT_IDENTITY_MATRIX;
    this.leading = 0;
    // Current point (in user coordinates)
    this.x = 0;
    this.y = 0;
    // Start of text line (in text coordinates)
    this.lineX = 0;
    this.lineY = 0;
    // Character and word spacing
    this.charSpacing = 0;
    this.wordSpacing = 0;
    this.textHScale = 1;
    this.textRenderingMode = TextRenderingMode.FILL;
    this.textRise = 0;
    // Default fore and background colors
    this.fillColor = "#000000";
    this.strokeColor = "#000000";
    this.patternFill = false;
    // Note: fill alpha applies to all non-stroking operations
    this.fillAlpha = 1;
    this.strokeAlpha = 1;
    this.lineWidth = 1;
    this.activeSMask = null;
    this.transferMaps = null;

    this.startNewPathAndClipBox([0, 0, width, height]);
  }

  clone() {
    const clone = Object.create(this);
    clone.clipBox = this.clipBox.slice();
    return clone;
  }

  setCurrentPoint(x, y) {
    this.x = x;
    this.y = y;
  }

  updatePathMinMax(transform, x, y) {
    [x, y] = Util.applyTransform([x, y], transform);
    this.minX = Math.min(this.minX, x);
    this.minY = Math.min(this.minY, y);
    this.maxX = Math.max(this.maxX, x);
    this.maxY = Math.max(this.maxY, y);
  }

  updateRectMinMax(transform, rect) {
    const p1 = Util.applyTransform(rect, transform);
    const p2 = Util.applyTransform(rect.slice(2), transform);
    this.minX = Math.min(this.minX, p1[0], p2[0]);
    this.minY = Math.min(this.minY, p1[1], p2[1]);
    this.maxX = Math.max(this.maxX, p1[0], p2[0]);
    this.maxY = Math.max(this.maxY, p1[1], p2[1]);
  }

  updateScalingPathMinMax(transform, minMax) {
    Util.scaleMinMax(transform, minMax);
    this.minX = Math.min(this.minX, minMax[0]);
    this.maxX = Math.max(this.maxX, minMax[1]);
    this.minY = Math.min(this.minY, minMax[2]);
    this.maxY = Math.max(this.maxY, minMax[3]);
  }

  updateCurvePathMinMax(transform, x0, y0, x1, y1, x2, y2, x3, y3, minMax) {
    const box = Util.bezierBoundingBox(x0, y0, x1, y1, x2, y2, x3, y3);
    if (minMax) {
      minMax[0] = Math.min(minMax[0], box[0], box[2]);
      minMax[1] = Math.max(minMax[1], box[0], box[2]);
      minMax[2] = Math.min(minMax[2], box[1], box[3]);
      minMax[3] = Math.max(minMax[3], box[1], box[3]);
      return;
    }
    this.updateRectMinMax(transform, box);
  }

  getPathBoundingBox(pathType = PathType.FILL, transform = null) {
    const box = [this.minX, this.minY, this.maxX, this.maxY];
    if (pathType === PathType.STROKE) {
      if (!transform) {
        unreachable("Stroke bounding box must include transform.");
      }
      // Stroked paths can be outside of the path bounding box by 1/2 the line
      // width.
      const scale = Util.singularValueDecompose2dScale(transform);
      const xStrokePad = (scale[0] * this.lineWidth) / 2;
      const yStrokePad = (scale[1] * this.lineWidth) / 2;
      box[0] -= xStrokePad;
      box[1] -= yStrokePad;
      box[2] += xStrokePad;
      box[3] += yStrokePad;
    }
    return box;
  }

  updateClipFromPath() {
    const intersect = Util.intersect(this.clipBox, this.getPathBoundingBox());
    this.startNewPathAndClipBox(intersect || [0, 0, 0, 0]);
  }

  isEmptyClip() {
    return this.minX === Infinity;
  }

  startNewPathAndClipBox(box) {
    this.clipBox = box;
    this.minX = Infinity;
    this.minY = Infinity;
    this.maxX = 0;
    this.maxY = 0;
  }

  getClippedPathBoundingBox(pathType = PathType.FILL, transform = null) {
    return Util.intersect(
      this.clipBox,
      this.getPathBoundingBox(pathType, transform)
    );
  }
}

function putBinaryImageData(ctx, imgData, transferMaps = null) {
  if (typeof ImageData !== "undefined" && imgData instanceof ImageData) {
    ctx.putImageData(imgData, 0, 0);
    return;
  }

  // Put the image data to the canvas in chunks, rather than putting the
  // whole image at once.  This saves JS memory, because the ImageData object
  // is smaller. It also possibly saves C++ memory within the implementation
  // of putImageData(). (E.g. in Firefox we make two short-lived copies of
  // the data passed to putImageData()). |n| shouldn't be too small, however,
  // because too many putImageData() calls will slow things down.
  //
  // Note: as written, if the last chunk is partial, the putImageData() call
  // will (conceptually) put pixels past the bounds of the canvas.  But
  // that's ok; any such pixels are ignored.

  const height = imgData.height,
    width = imgData.width;
  const partialChunkHeight = height % FULL_CHUNK_HEIGHT;
  const fullChunks = (height - partialChunkHeight) / FULL_CHUNK_HEIGHT;
  const totalChunks = partialChunkHeight === 0 ? fullChunks : fullChunks + 1;

  const chunkImgData = ctx.createImageData(width, FULL_CHUNK_HEIGHT);
  let srcPos = 0,
    destPos;
  const src = imgData.data;
  const dest = chunkImgData.data;
  let i, j, thisChunkHeight, elemsInThisChunk;

  let transferMapRed, transferMapGreen, transferMapBlue, transferMapGray;
  if (transferMaps) {
    switch (transferMaps.length) {
      case 1:
        transferMapRed = transferMaps[0];
        transferMapGreen = transferMaps[0];
        transferMapBlue = transferMaps[0];
        transferMapGray = transferMaps[0];
        break;
      case 4:
        transferMapRed = transferMaps[0];
        transferMapGreen = transferMaps[1];
        transferMapBlue = transferMaps[2];
        transferMapGray = transferMaps[3];
        break;
    }
  }

  // There are multiple forms in which the pixel data can be passed, and
  // imgData.kind tells us which one this is.
  if (imgData.kind === ImageKind.GRAYSCALE_1BPP) {
    // Grayscale, 1 bit per pixel (i.e. black-and-white).
    const srcLength = src.byteLength;
    const dest32 = new Uint32Array(dest.buffer, 0, dest.byteLength >> 2);
    const dest32DataLength = dest32.length;
    const fullSrcDiff = (width + 7) >> 3;
    let white = 0xffffffff;
    let black = FeatureTest.isLittleEndian ? 0xff000000 : 0x000000ff;

    if (transferMapGray) {
      if (transferMapGray[0] === 0xff && transferMapGray[0xff] === 0) {
        [white, black] = [black, white];
      }
    }

    for (i = 0; i < totalChunks; i++) {
      thisChunkHeight = i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight;
      destPos = 0;
      for (j = 0; j < thisChunkHeight; j++) {
        const srcDiff = srcLength - srcPos;
        let k = 0;
        const kEnd = srcDiff > fullSrcDiff ? width : srcDiff * 8 - 7;
        const kEndUnrolled = kEnd & ~7;
        let mask = 0;
        let srcByte = 0;
        for (; k < kEndUnrolled; k += 8) {
          srcByte = src[srcPos++];
          dest32[destPos++] = srcByte & 128 ? white : black;
          dest32[destPos++] = srcByte & 64 ? white : black;
          dest32[destPos++] = srcByte & 32 ? white : black;
          dest32[destPos++] = srcByte & 16 ? white : black;
          dest32[destPos++] = srcByte & 8 ? white : black;
          dest32[destPos++] = srcByte & 4 ? white : black;
          dest32[destPos++] = srcByte & 2 ? white : black;
          dest32[destPos++] = srcByte & 1 ? white : black;
        }
        for (; k < kEnd; k++) {
          if (mask === 0) {
            srcByte = src[srcPos++];
            mask = 128;
          }

          dest32[destPos++] = srcByte & mask ? white : black;
          mask >>= 1;
        }
      }
      // We ran out of input. Make all remaining pixels transparent.
      while (destPos < dest32DataLength) {
        dest32[destPos++] = 0;
      }

      ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
    }
  } else if (imgData.kind === ImageKind.RGBA_32BPP) {
    // RGBA, 32-bits per pixel.
    const hasTransferMaps = !!(
      transferMapRed ||
      transferMapGreen ||
      transferMapBlue
    );

    j = 0;
    elemsInThisChunk = width * FULL_CHUNK_HEIGHT * 4;
    for (i = 0; i < fullChunks; i++) {
      dest.set(src.subarray(srcPos, srcPos + elemsInThisChunk));
      srcPos += elemsInThisChunk;

      if (hasTransferMaps) {
        for (let k = 0; k < elemsInThisChunk; k += 4) {
          if (transferMapRed) {
            dest[k + 0] = transferMapRed[dest[k + 0]];
          }
          if (transferMapGreen) {
            dest[k + 1] = transferMapGreen[dest[k + 1]];
          }
          if (transferMapBlue) {
            dest[k + 2] = transferMapBlue[dest[k + 2]];
          }
        }
      }

      ctx.putImageData(chunkImgData, 0, j);
      j += FULL_CHUNK_HEIGHT;
    }
    if (i < totalChunks) {
      elemsInThisChunk = width * partialChunkHeight * 4;
      dest.set(src.subarray(srcPos, srcPos + elemsInThisChunk));

      if (hasTransferMaps) {
        for (let k = 0; k < elemsInThisChunk; k += 4) {
          if (transferMapRed) {
            dest[k + 0] = transferMapRed[dest[k + 0]];
          }
          if (transferMapGreen) {
            dest[k + 1] = transferMapGreen[dest[k + 1]];
          }
          if (transferMapBlue) {
            dest[k + 2] = transferMapBlue[dest[k + 2]];
          }
        }
      }

      ctx.putImageData(chunkImgData, 0, j);
    }
  } else if (imgData.kind === ImageKind.RGB_24BPP) {
    // RGB, 24-bits per pixel.
    const hasTransferMaps = !!(
      transferMapRed ||
      transferMapGreen ||
      transferMapBlue
    );

    thisChunkHeight = FULL_CHUNK_HEIGHT;
    elemsInThisChunk = width * thisChunkHeight;
    for (i = 0; i < totalChunks; i++) {
      if (i >= fullChunks) {
        thisChunkHeight = partialChunkHeight;
        elemsInThisChunk = width * thisChunkHeight;
      }

      destPos = 0;
      for (j = elemsInThisChunk; j--; ) {
        dest[destPos++] = src[srcPos++];
        dest[destPos++] = src[srcPos++];
        dest[destPos++] = src[srcPos++];
        dest[destPos++] = 255;
      }

      if (hasTransferMaps) {
        for (let k = 0; k < destPos; k += 4) {
          if (transferMapRed) {
            dest[k + 0] = transferMapRed[dest[k + 0]];
          }
          if (transferMapGreen) {
            dest[k + 1] = transferMapGreen[dest[k + 1]];
          }
          if (transferMapBlue) {
            dest[k + 2] = transferMapBlue[dest[k + 2]];
          }
        }
      }

      ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
    }
  } else {
    throw new Error(`bad image kind: ${imgData.kind}`);
  }
}

function putBinaryImageMask(ctx, imgData) {
  if (imgData.bitmap) {
    // The bitmap has been created in the worker.
    ctx.drawImage(imgData.bitmap, 0, 0);
    return;
  }

  // Slow path: OffscreenCanvas isn't available in the worker.
  const height = imgData.height,
    width = imgData.width;
  const partialChunkHeight = height % FULL_CHUNK_HEIGHT;
  const fullChunks = (height - partialChunkHeight) / FULL_CHUNK_HEIGHT;
  const totalChunks = partialChunkHeight === 0 ? fullChunks : fullChunks + 1;

  const chunkImgData = ctx.createImageData(width, FULL_CHUNK_HEIGHT);
  let srcPos = 0;
  const src = imgData.data;
  const dest = chunkImgData.data;

  for (let i = 0; i < totalChunks; i++) {
    const thisChunkHeight =
      i < fullChunks ? FULL_CHUNK_HEIGHT : partialChunkHeight;

    // Expand the mask so it can be used by the canvas.  Any required
    // inversion has already been handled.

    ({ srcPos } = applyMaskImageData({
      src,
      srcPos,
      dest,
      width,
      height: thisChunkHeight,
    }));

    ctx.putImageData(chunkImgData, 0, i * FULL_CHUNK_HEIGHT);
  }
}

function copyCtxState(sourceCtx, destCtx) {
  const properties = [
    "strokeStyle",
    "fillStyle",
    "fillRule",
    "globalAlpha",
    "lineWidth",
    "lineCap",
    "lineJoin",
    "miterLimit",
    "globalCompositeOperation",
    "font",
  ];
  for (let i = 0, ii = properties.length; i < ii; i++) {
    const property = properties[i];
    if (sourceCtx[property] !== undefined) {
      destCtx[property] = sourceCtx[property];
    }
  }
  if (sourceCtx.setLineDash !== undefined) {
    destCtx.setLineDash(sourceCtx.getLineDash());
    destCtx.lineDashOffset = sourceCtx.lineDashOffset;
  }
}

function resetCtxToDefault(ctx, foregroundColor) {
  ctx.strokeStyle = ctx.fillStyle = foregroundColor || "#000000";
  ctx.fillRule = "nonzero";
  ctx.globalAlpha = 1;
  ctx.lineWidth = 1;
  ctx.lineCap = "butt";
  ctx.lineJoin = "miter";
  ctx.miterLimit = 10;
  ctx.globalCompositeOperation = "source-over";
  ctx.font = "10px sans-serif";
  if (ctx.setLineDash !== undefined) {
    ctx.setLineDash([]);
    ctx.lineDashOffset = 0;
  }
}

function composeSMaskBackdrop(bytes, r0, g0, b0) {
  const length = bytes.length;
  for (let i = 3; i < length; i += 4) {
    const alpha = bytes[i];
    if (alpha === 0) {
      bytes[i - 3] = r0;
      bytes[i - 2] = g0;
      bytes[i - 1] = b0;
    } else if (alpha < 255) {
      const alpha_ = 255 - alpha;
      bytes[i - 3] = (bytes[i - 3] * alpha + r0 * alpha_) >> 8;
      bytes[i - 2] = (bytes[i - 2] * alpha + g0 * alpha_) >> 8;
      bytes[i - 1] = (bytes[i - 1] * alpha + b0 * alpha_) >> 8;
    }
  }
}

function composeSMaskAlpha(maskData, layerData, transferMap) {
  const length = maskData.length;
  const scale = 1 / 255;
  for (let i = 3; i < length; i += 4) {
    const alpha = transferMap ? transferMap[maskData[i]] : maskData[i];
    layerData[i] = (layerData[i] * alpha * scale) | 0;
  }
}

function composeSMaskLuminosity(maskData, layerData, transferMap) {
  const length = maskData.length;
  for (let i = 3; i < length; i += 4) {
    const y =
      maskData[i - 3] * 77 + // * 0.3 / 255 * 0x10000
      maskData[i - 2] * 152 + // * 0.59 ....
      maskData[i - 1] * 28; // * 0.11 ....
    layerData[i] = transferMap
      ? (layerData[i] * transferMap[y >> 8]) >> 8
      : (layerData[i] * y) >> 16;
  }
}

function genericComposeSMask(
  maskCtx,
  layerCtx,
  width,
  height,
  subtype,
  backdrop,
  transferMap,
  layerOffsetX,
  layerOffsetY,
  maskOffsetX,
  maskOffsetY
) {
  const hasBackdrop = !!backdrop;
  const r0 = hasBackdrop ? backdrop[0] : 0;
  const g0 = hasBackdrop ? backdrop[1] : 0;
  const b0 = hasBackdrop ? backdrop[2] : 0;

  let composeFn;
  if (subtype === "Luminosity") {
    composeFn = composeSMaskLuminosity;
  } else {
    composeFn = composeSMaskAlpha;
  }

  // processing image in chunks to save memory
  const PIXELS_TO_PROCESS = 1048576;
  const chunkSize = Math.min(height, Math.ceil(PIXELS_TO_PROCESS / width));
  for (let row = 0; row < height; row += chunkSize) {
    const chunkHeight = Math.min(chunkSize, height - row);
    const maskData = maskCtx.getImageData(
      layerOffsetX - maskOffsetX,
      row + (layerOffsetY - maskOffsetY),
      width,
      chunkHeight
    );
    const layerData = layerCtx.getImageData(
      layerOffsetX,
      row + layerOffsetY,
      width,
      chunkHeight
    );

    if (hasBackdrop) {
      composeSMaskBackdrop(maskData.data, r0, g0, b0);
    }
    composeFn(maskData.data, layerData.data, transferMap);

    layerCtx.putImageData(layerData, layerOffsetX, row + layerOffsetY);
  }
}

function composeSMask(ctx, smask, layerCtx, layerBox) {
  const layerOffsetX = layerBox[0];
  const layerOffsetY = layerBox[1];
  const layerWidth = layerBox[2] - layerOffsetX;
  const layerHeight = layerBox[3] - layerOffsetY;
  if (layerWidth === 0 || layerHeight === 0) {
    return;
  }
  genericComposeSMask(
    smask.context,
    layerCtx,
    layerWidth,
    layerHeight,
    smask.subtype,
    smask.backdrop,
    smask.transferMap,
    layerOffsetX,
    layerOffsetY,
    smask.offsetX,
    smask.offsetY
  );
  ctx.save();
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = "source-over";
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.drawImage(layerCtx.canvas, 0, 0);
  ctx.restore();
}

function getImageSmoothingEnabled(transform, interpolate) {
  const scale = Util.singularValueDecompose2dScale(transform);
  // Round to a 32bit float so that `<=` check below will pass for numbers that
  // are very close, but not exactly the same 64bit floats.
  scale[0] = Math.fround(scale[0]);
  scale[1] = Math.fround(scale[1]);
  const actualScale = Math.fround(
    (globalThis.devicePixelRatio || 1) * PixelsPerInch.PDF_TO_CSS_UNITS
  );
  if (interpolate !== undefined) {
    // If the value is explicitly set use it.
    return interpolate;
  } else if (scale[0] <= actualScale || scale[1] <= actualScale) {
    // Smooth when downscaling.
    return true;
  }
  // Don't smooth when upscaling.
  return false;
}

const LINE_CAP_STYLES = ["butt", "round", "square"];
const LINE_JOIN_STYLES = ["miter", "round", "bevel"];
const NORMAL_CLIP = {};
const EO_CLIP = {};

class CanvasGraphics {
  constructor(
    canvasCtx,
    commonObjs,
    objs,
    canvasFactory,
    imageLayer,
    optionalContentConfig,
    annotationCanvasMap,
    pageColors
  ) {
    this.ctx = canvasCtx;
    this.current = new CanvasExtraState(
      this.ctx.canvas.width,
      this.ctx.canvas.height
    );
    this.stateStack = [];
    this.pendingClip = null;
    this.pendingEOFill = false;
    this.res = null;
    this.xobjs = null;
    this.commonObjs = commonObjs;
    this.objs = objs;
    this.canvasFactory = canvasFactory;
    this.imageLayer = imageLayer;
    this.groupStack = [];
    this.processingType3 = null;
    // Patterns are painted relative to the initial page/form transform, see
    // PDF spec 8.7.2 NOTE 1.
    this.baseTransform = null;
    this.baseTransformStack = [];
    this.groupLevel = 0;
    this.smaskStack = [];
    this.smaskCounter = 0;
    this.tempSMask = null;
    this.suspendedCtx = null;
    this.contentVisible = true;
    this.markedContentStack = [];
    this.optionalContentConfig = optionalContentConfig;
    this.cachedCanvases = new CachedCanvases(this.canvasFactory);
    this.cachedPatterns = new Map();
    this.annotationCanvasMap = annotationCanvasMap;
    this.viewportScale = 1;
    this.outputScaleX = 1;
    this.outputScaleY = 1;
    this.backgroundColor = pageColors?.background || null;
    this.foregroundColor = pageColors?.foreground || null;
    if (canvasCtx) {
      // NOTE: if mozCurrentTransform is polyfilled, then the current state of
      // the transformation must already be set in canvasCtx._transformMatrix.
      addContextCurrentTransform(canvasCtx);
    }
    this._cachedScaleForStroking = null;
    this._cachedGetSinglePixelWidth = null;
    this._cachedBitmapsMap = new Map();
  }

  getObject(data, fallback = null) {
    if (typeof data === "string") {
      return data.startsWith("g_")
        ? this.commonObjs.get(data)
        : this.objs.get(data);
    }
    return fallback;
  }

  beginDrawing({
    transform,
    viewport,
    transparency = false,
    background = null,
  }) {
    // For pdfs that use blend modes we have to clear the canvas else certain
    // blend modes can look wrong since we'd be blending with a white
    // backdrop. The problem with a transparent backdrop though is we then
    // don't get sub pixel anti aliasing on text, creating temporary
    // transparent canvas when we have blend modes.
    const width = this.ctx.canvas.width;
    const height = this.ctx.canvas.height;
    const defaultBackgroundColor = background || "#ffffff";
    this.ctx.save();

    if (this.foregroundColor && this.backgroundColor) {
      // Get the #RRGGBB value of the color. If it's a name (e.g. CanvasText)
      // then it'll be converted to its rgb value.
      this.ctx.fillStyle = this.foregroundColor;
      const fg = (this.foregroundColor = this.ctx.fillStyle);
      this.ctx.fillStyle = this.backgroundColor;
      const bg = (this.backgroundColor = this.ctx.fillStyle);
      let isValidDefaultBg = true;
      let defaultBg = defaultBackgroundColor;

      if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) {
        this.ctx.fillStyle = defaultBackgroundColor;
        defaultBg = this.ctx.fillStyle;
        isValidDefaultBg =
          typeof defaultBg === "string" && /^#[0-9A-Fa-f]{6}$/.test(defaultBg);
      }

      if (
        (fg === "#000000" && bg === "#ffffff") ||
        fg === bg ||
        !isValidDefaultBg
      ) {
        // Ignore the `pageColors`-option when:
        //  - The computed background/foreground colors have their default
        //    values, i.e. white/black.
        //  - The computed background/foreground colors are identical,
        //    since that'd render the `canvas` mostly blank.
        //  - The `background`-option has a value that's incompatible with
        //    the `pageColors`-values.
        //
        this.foregroundColor = this.backgroundColor = null;
      } else {
        // https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_Colors_and_Luminance
        //
        // Relative luminance:
        // https://www.w3.org/TR/WCAG20/#relativeluminancedef
        //
        // We compute the rounded luminance of the default background color.
        // Then for every color in the pdf, if its rounded luminance is the
        // same as the background one then it's replaced by the new
        // background color else by the foreground one.
        const [rB, gB, bB] = getRGB(defaultBg);
        const newComp = x => {
          x /= 255;
          return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
        };
        const lumB = Math.round(
          0.2126 * newComp(rB) + 0.7152 * newComp(gB) + 0.0722 * newComp(bB)
        );
        this.selectColor = (r, g, b) => {
          const lumC =
            0.2126 * newComp(r) + 0.7152 * newComp(g) + 0.0722 * newComp(b);
          return Math.round(lumC) === lumB ? bg : fg;
        };
      }
    }

    this.ctx.fillStyle = this.backgroundColor || defaultBackgroundColor;
    this.ctx.fillRect(0, 0, width, height);
    this.ctx.restore();

    if (transparency) {
      const transparentCanvas = this.cachedCanvases.getCanvas(
        "transparent",
        width,
        height,
        /* trackTransform */ true
      );
      this.compositeCtx = this.ctx;
      this.transparentCanvas = transparentCanvas.canvas;
      this.ctx = transparentCanvas.context;
      this.ctx.save();
      // The transform can be applied before rendering, transferring it to
      // the new canvas.
      this.ctx.transform.apply(this.ctx, this.compositeCtx.mozCurrentTransform);
    }

    this.ctx.save();
    resetCtxToDefault(this.ctx, this.foregroundColor);
    if (transform) {
      this.ctx.transform.apply(this.ctx, transform);
      this.outputScaleX = transform[0];
      this.outputScaleY = transform[0];
    }
    this.ctx.transform.apply(this.ctx, viewport.transform);
    this.viewportScale = viewport.scale;

    this.baseTransform = this.ctx.mozCurrentTransform.slice();

    if (this.imageLayer) {
      this.imageLayer.beginLayout();
    }
  }

  executeOperatorList(
    operatorList,
    executionStartIdx,
    continueCallback,
    stepper
  ) {
    const argsArray = operatorList.argsArray;
    const fnArray = operatorList.fnArray;
    let i = executionStartIdx || 0;
    const argsArrayLen = argsArray.length;

    // Sometimes the OperatorList to execute is empty.
    if (argsArrayLen === i) {
      return i;
    }

    const chunkOperations =
      argsArrayLen - i > EXECUTION_STEPS &&
      typeof continueCallback === "function";
    const endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;
    let steps = 0;

    const commonObjs = this.commonObjs;
    const objs = this.objs;
    let fnId;

    while (true) {
      if (stepper !== undefined && i === stepper.nextBreakPoint) {
        stepper.breakIt(i, continueCallback);
        return i;
      }

      fnId = fnArray[i];

      if (fnId !== OPS.dependency) {
        this[fnId].apply(this, argsArray[i]);
      } else {
        for (const depObjId of argsArray[i]) {
          const objsPool = depObjId.startsWith("g_") ? commonObjs : objs;

          // If the promise isn't resolved yet, add the continueCallback
          // to the promise and bail out.
          if (!objsPool.has(depObjId)) {
            objsPool.get(depObjId, continueCallback);
            return i;
          }
        }
      }

      i++;

      // If the entire operatorList was executed, stop as were done.
      if (i === argsArrayLen) {
        return i;
      }

      // If the execution took longer then a certain amount of time and
      // `continueCallback` is specified, interrupt the execution.
      if (chunkOperations && ++steps > EXECUTION_STEPS) {
        if (Date.now() > endTime) {
          continueCallback();
          return i;
        }
        steps = 0;
      }

      // If the operatorList isn't executed completely yet OR the execution
      // time was short enough, do another execution round.
    }
  }

  #restoreInitialState() {
    // Finishing all opened operations such as SMask group painting.
    while (this.stateStack.length || this.inSMaskMode) {
      this.restore();
    }

    this.ctx.restore();

    if (this.transparentCanvas) {
      this.ctx = this.compositeCtx;
      this.ctx.save();
      this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Avoid apply transform twice
      this.ctx.drawImage(this.transparentCanvas, 0, 0);
      this.ctx.restore();
      this.transparentCanvas = null;
    }
  }

  endDrawing() {
    this.#restoreInitialState();

    this.cachedCanvases.clear();
    this.cachedPatterns.clear();

    for (const cache of this._cachedBitmapsMap.values()) {
      for (const canvas of cache.values()) {
        if (
          typeof HTMLCanvasElement !== "undefined" &&
          canvas instanceof HTMLCanvasElement
        ) {
          canvas.width = canvas.height = 0;
        }
      }
      cache.clear();
    }
    this._cachedBitmapsMap.clear();

    if (this.imageLayer) {
      this.imageLayer.endLayout();
    }
  }

  _scaleImage(img, inverseTransform) {
    // Vertical or horizontal scaling shall not be more than 2 to not lose the
    // pixels during drawImage operation, painting on the temporary canvas(es)
    // that are twice smaller in size.
    const width = img.width;
    const height = img.height;
    let widthScale = Math.max(
      Math.hypot(inverseTransform[0], inverseTransform[1]),
      1
    );
    let heightScale = Math.max(
      Math.hypot(inverseTransform[2], inverseTransform[3]),
      1
    );

    let paintWidth = width,
      paintHeight = height;
    let tmpCanvasId = "prescale1";
    let tmpCanvas, tmpCtx;
    while (
      (widthScale > 2 && paintWidth > 1) ||
      (heightScale > 2 && paintHeight > 1)
    ) {
      let newWidth = paintWidth,
        newHeight = paintHeight;
      if (widthScale > 2 && paintWidth > 1) {
        newWidth = Math.ceil(paintWidth / 2);
        widthScale /= paintWidth / newWidth;
      }
      if (heightScale > 2 && paintHeight > 1) {
        newHeight = Math.ceil(paintHeight / 2);
        heightScale /= paintHeight / newHeight;
      }
      tmpCanvas = this.cachedCanvases.getCanvas(
        tmpCanvasId,
        newWidth,
        newHeight,
        /* trackTransform */ false
      );
      tmpCtx = tmpCanvas.context;
      tmpCtx.clearRect(0, 0, newWidth, newHeight);
      tmpCtx.drawImage(
        img,
        0,
        0,
        paintWidth,
        paintHeight,
        0,
        0,
        newWidth,
        newHeight
      );
      img = tmpCanvas.canvas;
      paintWidth = newWidth;
      paintHeight = newHeight;
      tmpCanvasId = tmpCanvasId === "prescale1" ? "prescale2" : "prescale1";
    }
    return {
      img,
      paintWidth,
      paintHeight,
    };
  }

  _createMaskCanvas(img) {
    const ctx = this.ctx;
    const { width, height } = img;
    const fillColor = this.current.fillColor;
    const isPatternFill = this.current.patternFill;
    const currentTransform = ctx.mozCurrentTransform;

    let cache, cacheKey, scaled, maskCanvas;
    if ((img.bitmap || img.data) && img.count > 1) {
      const mainKey = img.bitmap || img.data.buffer;
      // We're reusing the same image several times, so we can cache it.
      // In case we've a pattern fill we just keep the scaled version of
      // the image.
      // Only the scaling part matters, the translation part is just used
      // to compute offsets.
      // TODO: handle the case of a pattern fill if it's possible.
      const withoutTranslation = currentTransform.slice(0, 4);
      cacheKey = JSON.stringify(
        isPatternFill ? withoutTranslation : [withoutTranslation, fillColor]
      );

      cache = this._cachedBitmapsMap.get(mainKey);
      if (!cache) {
        cache = new Map();
        this._cachedBitmapsMap.set(mainKey, cache);
      }
      const cachedImage = cache.get(cacheKey);
      if (cachedImage && !isPatternFill) {
        const offsetX = Math.round(
          Math.min(currentTransform[0], currentTransform[2]) +
            currentTransform[4]
        );
        const offsetY = Math.round(
          Math.min(currentTransform[1], currentTransform[3]) +
            currentTransform[5]
        );
        return {
          canvas: cachedImage,
          offsetX,
          offsetY,
        };
      }
      scaled = cachedImage;
    }

    if (!scaled) {
      maskCanvas = this.cachedCanvases.getCanvas(
        "maskCanvas",
        width,
        height,
        /* trackTransform */ false
      );
      putBinaryImageMask(maskCanvas.context, img);
    }

    // Create the mask canvas at the size it will be drawn at and also set
    // its transform to match the current transform so if there are any
    // patterns applied they will be applied relative to the correct
    // transform.

    let maskToCanvas = Util.transform(currentTransform, [
      1 / width,
      0,
      0,
      -1 / height,
      0,
      0,
    ]);
    maskToCanvas = Util.transform(maskToCanvas, [1, 0, 0, 1, 0, -height]);
    const cord1 = Util.applyTransform([0, 0], maskToCanvas);
    const cord2 = Util.applyTransform([width, height], maskToCanvas);
    const rect = Util.normalizeRect([cord1[0], cord1[1], cord2[0], cord2[1]]);
    const drawnWidth = Math.round(rect[2] - rect[0]) || 1;
    const drawnHeight = Math.round(rect[3] - rect[1]) || 1;
    const fillCanvas = this.cachedCanvases.getCanvas(
      "fillCanvas",
      drawnWidth,
      drawnHeight,
      /* trackTransform */ true
    );
    const fillCtx = fillCanvas.context;

    // The offset will be the top-left cordinate mask.
    // If objToCanvas is [a,b,c,d,e,f] then:
    //   - offsetX = min(a, c) + e
    //   - offsetY = min(b, d) + f
    const offsetX = Math.min(cord1[0], cord2[0]);
    const offsetY = Math.min(cord1[1], cord2[1]);
    fillCtx.translate(-offsetX, -offsetY);
    fillCtx.transform.apply(fillCtx, maskToCanvas);

    if (!scaled) {
      // Pre-scale if needed to improve image smoothing.
      scaled = this._scaleImage(
        maskCanvas.canvas,
        fillCtx.mozCurrentTransformInverse
      );
      scaled = scaled.img;
      if (cache && isPatternFill) {
        cache.set(cacheKey, scaled);
      }
    }

    fillCtx.imageSmoothingEnabled = getImageSmoothingEnabled(
      fillCtx.mozCurrentTransform,
      img.interpolate
    );

    drawImageAtIntegerCoords(
      fillCtx,
      scaled,
      0,
      0,
      scaled.width,
      scaled.height,
      0,
      0,
      width,
      height
    );
    fillCtx.globalCompositeOperation = "source-in";

    const inverse = Util.transform(fillCtx.mozCurrentTransformInverse, [
      1,
      0,
      0,
      1,
      -offsetX,
      -offsetY,
    ]);
    fillCtx.fillStyle = isPatternFill
      ? fillColor.getPattern(ctx, this, inverse, PathType.FILL)
      : fillColor;

    fillCtx.fillRect(0, 0, width, height);

    if (cache && !isPatternFill) {
      // The fill canvas is put in the cache associated to the mask image
      // so we must remove from the cached canvas: it mustn't be used again.
      this.cachedCanvases.delete("fillCanvas");
      cache.set(cacheKey, fillCanvas.canvas);
    }

    // Round the offsets to avoid drawing fractional pixels.
    return {
      canvas: fillCanvas.canvas,
      offsetX: Math.round(offsetX),
      offsetY: Math.round(offsetY),
    };
  }

  // Graphics state
  setLineWidth(width) {
    if (width !== this.current.lineWidth) {
      this._cachedScaleForStroking = null;
    }
    this.current.lineWidth = width;
    this.ctx.lineWidth = width;
  }

  setLineCap(style) {
    this.ctx.lineCap = LINE_CAP_STYLES[style];
  }

  setLineJoin(style) {
    this.ctx.lineJoin = LINE_JOIN_STYLES[style];
  }

  setMiterLimit(limit) {
    this.ctx.miterLimit = limit;
  }

  setDash(dashArray, dashPhase) {
    const ctx = this.ctx;
    if (ctx.setLineDash !== undefined) {
      ctx.setLineDash(dashArray);
      ctx.lineDashOffset = dashPhase;
    }
  }

  setRenderingIntent(intent) {
    // This operation is ignored since we haven't found a use case for it yet.
  }

  setFlatness(flatness) {
    // This operation is ignored since we haven't found a use case for it yet.
  }

  setGState(states) {
    for (let i = 0, ii = states.length; i < ii; i++) {
      const state = states[i];
      const key = state[0];
      const value = state[1];

      switch (key) {
        case "LW":
          this.setLineWidth(value);
          break;
        case "LC":
          this.setLineCap(value);
          break;
        case "LJ":
          this.setLineJoin(value);
          break;
        case "ML":
          this.setMiterLimit(value);
          break;
        case "D":
          this.setDash(value[0], value[1]);
          break;
        case "RI":
          this.setRenderingIntent(value);
          break;
        case "FL":
          this.setFlatness(value);
          break;
        case "Font":
          this.setFont(value[0], value[1]);
          break;
        case "CA":
          this.current.strokeAlpha = state[1];
          break;
        case "ca":
          this.current.fillAlpha = state[1];
          this.ctx.globalAlpha = state[1];
          break;
        case "BM":
          this.ctx.globalCompositeOperation = value;
          break;
        case "SMask":
          this.current.activeSMask = value ? this.tempSMask : null;
          this.tempSMask = null;
          this.checkSMaskState();
          break;
        case "TR":
          this.current.transferMaps = value;
      }
    }
  }

  get inSMaskMode() {
    return !!this.suspendedCtx;
  }

  checkSMaskState() {
    const inSMaskMode = this.inSMaskMode;
    if (this.current.activeSMask && !inSMaskMode) {
      this.beginSMaskMode();
    } else if (!this.current.activeSMask && inSMaskMode) {
      this.endSMaskMode();
    }
    // Else, the state is okay and nothing needs to be done.
  }

  /**
   * Soft mask mode takes the current main drawing canvas and replaces it with
   * a temporary canvas. Any drawing operations that happen on the temporary
   * canvas need to be composed with the main canvas that was suspended (see
   * `compose()`). The temporary canvas also duplicates many of its operations
   * on the suspended canvas to keep them in sync, so that when the soft mask
   * mode ends any clipping paths or transformations will still be active and in
   * the right order on the canvas' graphics state stack.
   */
  beginSMaskMode() {
    if (this.inSMaskMode) {
      throw new Error("beginSMaskMode called while already in smask mode");
    }
    const drawnWidth = this.ctx.canvas.width;
    const drawnHeight = this.ctx.canvas.height;
    const cacheId = "smaskGroupAt" + this.groupLevel;
    const scratchCanvas = this.cachedCanvases.getCanvas(
      cacheId,
      drawnWidth,
      drawnHeight,
      /* trackTransform */ true
    );
    this.suspendedCtx = this.ctx;
    this.ctx = scratchCanvas.context;
    const ctx = this.ctx;
    ctx.setTransform.apply(ctx, this.suspendedCtx.mozCurrentTransform);
    copyCtxState(this.suspendedCtx, ctx);
    mirrorContextOperations(ctx, this.suspendedCtx);

    this.setGState([
      ["BM", "source-over"],
      ["ca", 1],
      ["CA", 1],
    ]);
  }

  endSMaskMode() {
    if (!this.inSMaskMode) {
      throw new Error("endSMaskMode called while not in smask mode");
    }
    // The soft mask is done, now restore the suspended canvas as the main
    // drawing canvas.
    this.ctx._removeMirroring();
    copyCtxState(this.ctx, this.suspendedCtx);
    this.ctx = this.suspendedCtx;

    this.suspendedCtx = null;
  }

  compose(dirtyBox) {
    if (!this.current.activeSMask) {
      return;
    }

    if (!dirtyBox) {
      dirtyBox = [0, 0, this.ctx.canvas.width, this.ctx.canvas.height];
    } else {
      dirtyBox[0] = Math.floor(dirtyBox[0]);
      dirtyBox[1] = Math.floor(dirtyBox[1]);
      dirtyBox[2] = Math.ceil(dirtyBox[2]);
      dirtyBox[3] = Math.ceil(dirtyBox[3]);
    }
    const smask = this.current.activeSMask;
    const suspendedCtx = this.suspendedCtx;

    composeSMask(suspendedCtx, smask, this.ctx, dirtyBox);
    // Whatever was drawn has been moved to the suspended canvas, now clear it
    // out of the current canvas.
    this.ctx.save();
    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
    this.ctx.restore();
  }

  save() {
    if (this.inSMaskMode) {
      // SMask mode may be turned on/off causing us to lose graphics state.
      // Copy the temporary canvas state to the main(suspended) canvas to keep
      // it in sync.
      copyCtxState(this.ctx, this.suspendedCtx);
      // Don't bother calling save on the temporary canvas since state is not
      // saved there.
      this.suspendedCtx.save();
    } else {
      this.ctx.save();
    }
    const old = this.current;
    this.stateStack.push(old);
    this.current = old.clone();
  }

  restore() {
    if (this.stateStack.length === 0 && this.inSMaskMode) {
      this.endSMaskMode();
    }
    if (this.stateStack.length !== 0) {
      this.current = this.stateStack.pop();
      if (this.inSMaskMode) {
        // Graphics state is stored on the main(suspended) canvas. Restore its
        // state then copy it over to the temporary canvas.
        this.suspendedCtx.restore();
        copyCtxState(this.suspendedCtx, this.ctx);
      } else {
        this.ctx.restore();
      }
      this.checkSMaskState();

      // Ensure that the clipping path is reset (fixes issue6413.pdf).
      this.pendingClip = null;

      this._cachedScaleForStroking = null;
      this._cachedGetSinglePixelWidth = null;
    }
  }

  transform(a, b, c, d, e, f) {
    this.ctx.transform(a, b, c, d, e, f);

    this._cachedScaleForStroking = null;
    this._cachedGetSinglePixelWidth = null;
  }

  // Path
  constructPath(ops, args, minMax) {
    const ctx = this.ctx;
    const current = this.current;
    let x = current.x,
      y = current.y;
    let startX, startY;
    const currentTransform = ctx.mozCurrentTransform;

    // Most of the time the current transform is a scaling matrix
    // so we don't need to transform points before computing min/max:
    // we can compute min/max first and then smartly "apply" the
    // transform (see Util.scaleMinMax).
    // For rectangle, moveTo and lineTo, min/max are computed in the
    // worker (see evaluator.js).
    const isScalingMatrix =
      (currentTransform[0] === 0 && currentTransform[3] === 0) ||
      (currentTransform[1] === 0 && currentTransform[2] === 0);
    const minMaxForBezier = isScalingMatrix ? minMax.slice(0) : null;

    for (let i = 0, j = 0, ii = ops.length; i < ii; i++) {
      switch (ops[i] | 0) {
        case OPS.rectangle:
          x = args[j++];
          y = args[j++];
          const width = args[j++];
          const height = args[j++];

          const xw = x + width;
          const yh = y + height;
          ctx.moveTo(x, y);
          if (width === 0 || height === 0) {
            ctx.lineTo(xw, yh);
          } else {
            ctx.lineTo(xw, y);
            ctx.lineTo(xw, yh);
            ctx.lineTo(x, yh);
          }
          if (!isScalingMatrix) {
            current.updateRectMinMax(currentTransform, [x, y, xw, yh]);
          }
          ctx.closePath();
          break;
        case OPS.moveTo:
          x = args[j++];
          y = args[j++];
          ctx.moveTo(x, y);
          if (!isScalingMatrix) {
            current.updatePathMinMax(currentTransform, x, y);
          }
          break;
        case OPS.lineTo:
          x = args[j++];
          y = args[j++];
          ctx.lineTo(x, y);
          if (!isScalingMatrix) {
            current.updatePathMinMax(currentTransform, x, y);
          }
          break;
        case OPS.curveTo:
          startX = x;
          startY = y;
          x = args[j + 4];
          y = args[j + 5];
          ctx.bezierCurveTo(
            args[j],
            args[j + 1],
            args[j + 2],
            args[j + 3],
            x,
            y
          );
          current.updateCurvePathMinMax(
            currentTransform,
            startX,
            startY,
            args[j],
            args[j + 1],
            args[j + 2],
            args[j + 3],
            x,
            y,
            minMaxForBezier
          );
          j += 6;
          break;
        case OPS.curveTo2:
          startX = x;
          startY = y;
          ctx.bezierCurveTo(
            x,
            y,
            args[j],
            args[j + 1],
            args[j + 2],
            args[j + 3]
          );
          current.updateCurvePathMinMax(
            currentTransform,
            startX,
            startY,
            x,
            y,
            args[j],
            args[j + 1],
            args[j + 2],
            args[j + 3],
            minMaxForBezier
          );
          x = args[j + 2];
          y = args[j + 3];
          j += 4;
          break;
        case OPS.curveTo3:
          startX = x;
          startY = y;
          x = args[j + 2];
          y = args[j + 3];
          ctx.bezierCurveTo(args[j], args[j + 1], x, y, x, y);
          current.updateCurvePathMinMax(
            currentTransform,
            startX,
            startY,
            args[j],
            args[j + 1],
            x,
            y,
            x,
            y,
            minMaxForBezier
          );
          j += 4;
          break;
        case OPS.closePath:
          ctx.closePath();
          break;
      }
    }

    if (isScalingMatrix) {
      current.updateScalingPathMinMax(currentTransform, minMaxForBezier);
    }

    current.setCurrentPoint(x, y);
  }

  closePath() {
    this.ctx.closePath();
  }

  stroke(consumePath) {
    consumePath = typeof consumePath !== "undefined" ? consumePath : true;
    const ctx = this.ctx;
    const strokeColor = this.current.strokeColor;
    // For stroke we want to temporarily change the global alpha to the
    // stroking alpha.
    ctx.globalAlpha = this.current.strokeAlpha;
    if (this.contentVisible) {
      if (typeof strokeColor === "object" && strokeColor?.getPattern) {
        ctx.save();
        ctx.strokeStyle = strokeColor.getPattern(
          ctx,
          this,
          ctx.mozCurrentTransformInverse,
          PathType.STROKE
        );
        this.rescaleAndStroke(/* saveRestore */ false);
        ctx.restore();
      } else {
        this.rescaleAndStroke(/* saveRestore */ true);
      }
    }
    if (consumePath) {
      this.consumePath(this.current.getClippedPathBoundingBox());
    }
    // Restore the global alpha to the fill alpha
    ctx.globalAlpha = this.current.fillAlpha;
  }

  closeStroke() {
    this.closePath();
    this.stroke();
  }

  fill(consumePath) {
    consumePath = typeof consumePath !== "undefined" ? consumePath : true;
    const ctx = this.ctx;
    const fillColor = this.current.fillColor;
    const isPatternFill = this.current.patternFill;
    let needRestore = false;

    if (isPatternFill) {
      ctx.save();
      ctx.fillStyle = fillColor.getPattern(
        ctx,
        this,
        ctx.mozCurrentTransformInverse,
        PathType.FILL
      );
      needRestore = true;
    }

    const intersect = this.current.getClippedPathBoundingBox();
    if (this.contentVisible && intersect !== null) {
      if (this.pendingEOFill) {
        ctx.fill("evenodd");
        this.pendingEOFill = false;
      } else {
        ctx.fill();
      }
    }

    if (needRestore) {
      ctx.restore();
    }
    if (consumePath) {
      this.consumePath(intersect);
    }
  }

  eoFill() {
    this.pendingEOFill = true;
    this.fill();
  }

  fillStroke() {
    this.fill(false);
    this.stroke(false);

    this.consumePath();
  }

  eoFillStroke() {
    this.pendingEOFill = true;
    this.fillStroke();
  }

  closeFillStroke() {
    this.closePath();
    this.fillStroke();
  }

  closeEOFillStroke() {
    this.pendingEOFill = true;
    this.closePath();
    this.fillStroke();
  }

  endPath() {
    this.consumePath();
  }

  // Clipping
  clip() {
    this.pendingClip = NORMAL_CLIP;
  }

  eoClip() {
    this.pendingClip = EO_CLIP;
  }

  // Text
  beginText() {
    this.current.textMatrix = IDENTITY_MATRIX;
    this.current.textMatrixScale = 1;
    this.current.x = this.current.lineX = 0;
    this.current.y = this.current.lineY = 0;
  }

  endText() {
    const paths = this.pendingTextPaths;
    const ctx = this.ctx;
    if (paths === undefined) {
      ctx.beginPath();
      return;
    }

    ctx.save();
    ctx.beginPath();
    for (const path of paths) {
      ctx.setTransform.apply(ctx, path.transform);
      ctx.translate(path.x, path.y);
      path.addToPath(ctx, path.fontSize);
    }
    ctx.restore();
    ctx.clip();
    ctx.beginPath();
    delete this.pendingTextPaths;
  }

  setCharSpacing(spacing) {
    this.current.charSpacing = spacing;
  }

  setWordSpacing(spacing) {
    this.current.wordSpacing = spacing;
  }

  setHScale(scale) {
    this.current.textHScale = scale / 100;
  }

  setLeading(leading) {
    this.current.leading = -leading;
  }

  setFont(fontRefName, size) {
    const fontObj = this.commonObjs.get(fontRefName);
    const current = this.current;

    if (!fontObj) {
      throw new Error(`Can't find font for ${fontRefName}`);
    }
    current.fontMatrix = fontObj.fontMatrix || FONT_IDENTITY_MATRIX;

    // A valid matrix needs all main diagonal elements to be non-zero
    // This also ensures we bypass FF bugzilla bug #719844.
    if (current.fontMatrix[0] === 0 || current.fontMatrix[3] === 0) {
      warn("Invalid font matrix for font " + fontRefName);
    }

    // The spec for Tf (setFont) says that 'size' specifies the font 'scale',
    // and in some docs this can be negative (inverted x-y axes).
    if (size < 0) {
      size = -size;
      current.fontDirection = -1;
    } else {
      current.fontDirection = 1;
    }

    this.current.font = fontObj;
    this.current.fontSize = size;

    if (fontObj.isType3Font) {
      return; // we don't need ctx.font for Type3 fonts
    }

    const name = fontObj.loadedName || "sans-serif";

    let bold = "normal";
    if (fontObj.black) {
      bold = "900";
    } else if (fontObj.bold) {
      bold = "bold";
    }
    const italic = fontObj.italic ? "italic" : "normal";
    const typeface = `"${name}", ${fontObj.fallbackName}`;

    // Some font backends cannot handle fonts below certain size.
    // Keeping the font at minimal size and using the fontSizeScale to change
    // the current transformation matrix before the fillText/strokeText.
    // See https://bugzilla.mozilla.org/show_bug.cgi?id=726227
    let browserFontSize = size;
    if (size < MIN_FONT_SIZE) {
      browserFontSize = MIN_FONT_SIZE;
    } else if (size > MAX_FONT_SIZE) {
      browserFontSize = MAX_FONT_SIZE;
    }
    this.current.fontSizeScale = size / browserFontSize;

    this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;
  }

  setTextRenderingMode(mode) {
    this.current.textRenderingMode = mode;
  }

  setTextRise(rise) {
    this.current.textRise = rise;
  }

  moveText(x, y) {
    this.current.x = this.current.lineX += x;
    this.current.y = this.current.lineY += y;
  }

  setLeadingMoveText(x, y) {
    this.setLeading(-y);
    this.moveText(x, y);
  }

  setTextMatrix(a, b, c, d, e, f) {
    this.current.textMatrix = [a, b, c, d, e, f];
    this.current.textMatrixScale = Math.hypot(a, b);

    this.current.x = this.current.lineX = 0;
    this.current.y = this.current.lineY = 0;
  }

  nextLine() {
    this.moveText(0, this.current.leading);
  }

  paintChar(character, x, y, patternTransform) {
    const ctx = this.ctx;
    const current = this.current;
    const font = current.font;
    const textRenderingMode = current.textRenderingMode;
    const fontSize = current.fontSize / current.fontSizeScale;
    const fillStrokeMode =
      textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
    const isAddToPathSet = !!(
      textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG
    );
    const patternFill = current.patternFill && !font.missingFile;

    let addToPath;
    if (font.disableFontFace || isAddToPathSet || patternFill) {
      addToPath = font.getPathGenerator(this.commonObjs, character);
    }

    if (font.disableFontFace || patternFill) {
      ctx.save();
      ctx.translate(x, y);
      ctx.beginPath();
      addToPath(ctx, fontSize);
      if (patternTransform) {
        ctx.setTransform.apply(ctx, patternTransform);
      }
      if (
        fillStrokeMode === TextRenderingMode.FILL ||
        fillStrokeMode === TextRenderingMode.FILL_STROKE
      ) {
        ctx.fill();
      }
      if (
        fillStrokeMode === TextRenderingMode.STROKE ||
        fillStrokeMode === TextRenderingMode.FILL_STROKE
      ) {
        ctx.stroke();
      }
      ctx.restore();
    } else {
      if (
        fillStrokeMode === TextRenderingMode.FILL ||
        fillStrokeMode === TextRenderingMode.FILL_STROKE
      ) {
        ctx.fillText(character, x, y);
      }
      if (
        fillStrokeMode === TextRenderingMode.STROKE ||
        fillStrokeMode === TextRenderingMode.FILL_STROKE
      ) {
        ctx.strokeText(character, x, y);
      }
    }

    if (isAddToPathSet) {
      const paths = this.pendingTextPaths || (this.pendingTextPaths = []);
      paths.push({
        transform: ctx.mozCurrentTransform,
        x,
        y,
        fontSize,
        addToPath,
      });
    }
  }

  get isFontSubpixelAAEnabled() {
    // Checks if anti-aliasing is enabled when scaled text is painted.
    // On Windows GDI scaled fonts looks bad.
    const { context: ctx } = this.cachedCanvases.getCanvas(
      "isFontSubpixelAAEnabled",
      10,
      10,
      /* trackTransform */ false
    );
    ctx.scale(1.5, 1);
    ctx.fillText("I", 0, 10);
    const data = ctx.getImageData(0, 0, 10, 10).data;
    let enabled = false;
    for (let i = 3; i < data.length; i += 4) {
      if (data[i] > 0 && data[i] < 255) {
        enabled = true;
        break;
      }
    }
    return shadow(this, "isFontSubpixelAAEnabled", enabled);
  }

  showText(glyphs) {
    const current = this.current;
    const font = current.font;
    if (font.isType3Font) {
      return this.showType3Text(glyphs);
    }

    const fontSize = current.fontSize;
    if (fontSize === 0) {
      return undefined;
    }

    const ctx = this.ctx;
    const fontSizeScale = current.fontSizeScale;
    const charSpacing = current.charSpacing;
    const wordSpacing = current.wordSpacing;
    const fontDirection = current.fontDirection;
    const textHScale = current.textHScale * fontDirection;
    const glyphsLength = glyphs.length;
    const vertical = font.vertical;
    const spacingDir = vertical ? 1 : -1;
    const defaultVMetrics = font.defaultVMetrics;
    const widthAdvanceScale = fontSize * current.fontMatrix[0];

    const simpleFillText =
      current.textRenderingMode === TextRenderingMode.FILL &&
      !font.disableFontFace &&
      !current.patternFill;

    ctx.save();
    ctx.transform.apply(ctx, current.textMatrix);
    ctx.translate(current.x, current.y + current.textRise);

    if (fontDirection > 0) {
      ctx.scale(textHScale, -1);
    } else {
      ctx.scale(textHScale, 1);
    }

    let patternTransform;
    if (current.patternFill) {
      ctx.save();
      const pattern = current.fillColor.getPattern(
        ctx,
        this,
        ctx.mozCurrentTransformInverse,
        PathType.FILL
      );
      patternTransform = ctx.mozCurrentTransform;
      ctx.restore();
      ctx.fillStyle = pattern;
    }

    let lineWidth = current.lineWidth;
    const scale = current.textMatrixScale;
    if (scale === 0 || lineWidth === 0) {
      const fillStrokeMode =
        current.textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
      if (
        fillStrokeMode === TextRenderingMode.STROKE ||
        fillStrokeMode === TextRenderingMode.FILL_STROKE
      ) {
        lineWidth = this.getSinglePixelWidth();
      }
    } else {
      lineWidth /= scale;
    }

    if (fontSizeScale !== 1.0) {
      ctx.scale(fontSizeScale, fontSizeScale);
      lineWidth /= fontSizeScale;
    }

    ctx.lineWidth = lineWidth;

    let x = 0,
      i;
    for (i = 0; i < glyphsLength; ++i) {
      const glyph = glyphs[i];
      if (typeof glyph === "number") {
        x += (spacingDir * glyph * fontSize) / 1000;
        continue;
      }

      let restoreNeeded = false;
      const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
      const character = glyph.fontChar;
      const accent = glyph.accent;
      let scaledX, scaledY;
      let width = glyph.width;
      if (vertical) {
        const vmetric = glyph.vmetric || defaultVMetrics;
        const vx =
          -(glyph.vmetric ? vmetric[1] : width * 0.5) * widthAdvanceScale;
        const vy = vmetric[2] * widthAdvanceScale;

        width = vmetric ? -vmetric[0] : width;
        scaledX = vx / fontSizeScale;
        scaledY = (x + vy) / fontSizeScale;
      } else {
        scaledX = x / fontSizeScale;
        scaledY = 0;
      }

      if (font.remeasure && width > 0) {
        // Some standard fonts may not have the exact width: rescale per
        // character if measured width is greater than expected glyph width
        // and subpixel-aa is enabled, otherwise just center the glyph.
        const measuredWidth =
          ((ctx.measureText(character).width * 1000) / fontSize) *
          fontSizeScale;
        if (width < measuredWidth && this.isFontSubpixelAAEnabled) {
          const characterScaleX = width / measuredWidth;
          restoreNeeded = true;
          ctx.save();
          ctx.scale(characterScaleX, 1);
          scaledX /= characterScaleX;
        } else if (width !== measuredWidth) {
          scaledX +=
            (((width - measuredWidth) / 2000) * fontSize) / fontSizeScale;
        }
      }

      // Only attempt to draw the glyph if it is actually in the embedded font
      // file or if there isn't a font file so the fallback font is shown.
      if (this.contentVisible && (glyph.isInFont || font.missingFile)) {
        if (simpleFillText && !accent) {
          // common case
          ctx.fillText(character, scaledX, scaledY);
        } else {
          this.paintChar(character, scaledX, scaledY, patternTransform);
          if (accent) {
            const scaledAccentX =
              scaledX + (fontSize * accent.offset.x) / fontSizeScale;
            const scaledAccentY =
              scaledY - (fontSize * accent.offset.y) / fontSizeScale;
            this.paintChar(
              accent.fontChar,
              scaledAccentX,
              scaledAccentY,
              patternTransform
            );
          }
        }
      }

      let charWidth;
      if (vertical) {
        charWidth = width * widthAdvanceScale - spacing * fontDirection;
      } else {
        charWidth = width * widthAdvanceScale + spacing * fontDirection;
      }
      x += charWidth;

      if (restoreNeeded) {
        ctx.restore();
      }
    }
    if (vertical) {
      current.y -= x;
    } else {
      current.x += x * textHScale;
    }
    ctx.restore();
    this.compose();

    return undefined;
  }

  showType3Text(glyphs) {
    // Type3 fonts - each glyph is a "mini-PDF"
    const ctx = this.ctx;
    const current = this.current;
    const font = current.font;
    const fontSize = current.fontSize;
    const fontDirection = current.fontDirection;
    const spacingDir = font.vertical ? 1 : -1;
    const charSpacing = current.charSpacing;
    const wordSpacing = current.wordSpacing;
    const textHScale = current.textHScale * fontDirection;
    const fontMatrix = current.fontMatrix || FONT_IDENTITY_MATRIX;
    const glyphsLength = glyphs.length;
    const isTextInvisible =
      current.textRenderingMode === TextRenderingMode.INVISIBLE;
    let i, glyph, width, spacingLength;

    if (isTextInvisible || fontSize === 0) {
      return;
    }
    this._cachedScaleForStroking = null;
    this._cachedGetSinglePixelWidth = null;

    ctx.save();
    ctx.transform.apply(ctx, current.textMatrix);
    ctx.translate(current.x, current.y);

    ctx.scale(textHScale, fontDirection);

    for (i = 0; i < glyphsLength; ++i) {
      glyph = glyphs[i];
      if (typeof glyph === "number") {
        spacingLength = (spacingDir * glyph * fontSize) / 1000;
        this.ctx.translate(spacingLength, 0);
        current.x += spacingLength * textHScale;
        continue;
      }

      const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
      const operatorList = font.charProcOperatorList[glyph.operatorListId];
      if (!operatorList) {
        warn(`Type3 character "${glyph.operatorListId}" is not available.`);
        continue;
      }
      if (this.contentVisible) {
        this.processingType3 = glyph;
        this.save();
        ctx.scale(fontSize, fontSize);
        ctx.transform.apply(ctx, fontMatrix);
        this.executeOperatorList(operatorList);
        this.restore();
      }

      const transformed = Util.applyTransform([glyph.width, 0], fontMatrix);
      width = transformed[0] * fontSize + spacing;

      ctx.translate(width, 0);
      current.x += width * textHScale;
    }
    ctx.restore();
    this.processingType3 = null;
  }

  // Type3 fonts
  setCharWidth(xWidth, yWidth) {
    // We can safely ignore this since the width should be the same
    // as the width in the Widths array.
  }

  setCharWidthAndBounds(xWidth, yWidth, llx, lly, urx, ury) {
    this.ctx.rect(llx, lly, urx - llx, ury - lly);
    this.ctx.clip();
    this.endPath();
  }

  // Color
  getColorN_Pattern(IR) {
    let pattern;
    if (IR[0] === "TilingPattern") {
      const color = IR[1];
      const baseTransform =
        this.baseTransform || this.ctx.mozCurrentTransform.slice();
      const canvasGraphicsFactory = {
        createCanvasGraphics: ctx => {
          return new CanvasGraphics(
            ctx,
            this.commonObjs,
            this.objs,
            this.canvasFactory
          );
        },
      };
      pattern = new TilingPattern(
        IR,
        color,
        this.ctx,
        canvasGraphicsFactory,
        baseTransform
      );
    } else {
      pattern = this._getPattern(IR[1], IR[2]);
    }
    return pattern;
  }

  setStrokeColorN() {
    this.current.strokeColor = this.getColorN_Pattern(arguments);
  }

  setFillColorN() {
    this.current.fillColor = this.getColorN_Pattern(arguments);
    this.current.patternFill = true;
  }

  setStrokeRGBColor(r, g, b) {
    const color = this.selectColor?.(r, g, b) || Util.makeHexColor(r, g, b);
    this.ctx.strokeStyle = color;
    this.current.strokeColor = color;
  }

  setFillRGBColor(r, g, b) {
    const color = this.selectColor?.(r, g, b) || Util.makeHexColor(r, g, b);
    this.ctx.fillStyle = color;
    this.current.fillColor = color;
    this.current.patternFill = false;
  }

  _getPattern(objId, matrix = null) {
    let pattern;
    if (this.cachedPatterns.has(objId)) {
      pattern = this.cachedPatterns.get(objId);
    } else {
      pattern = getShadingPattern(this.objs.get(objId));
      this.cachedPatterns.set(objId, pattern);
    }
    if (matrix) {
      pattern.matrix = matrix;
    }
    return pattern;
  }

  shadingFill(objId) {
    if (!this.contentVisible) {
      return;
    }
    const ctx = this.ctx;

    this.save();
    const pattern = this._getPattern(objId);
    ctx.fillStyle = pattern.getPattern(
      ctx,
      this,
      ctx.mozCurrentTransformInverse,
      PathType.SHADING
    );

    const inv = ctx.mozCurrentTransformInverse;
    if (inv) {
      const canvas = ctx.canvas;
      const width = canvas.width;
      const height = canvas.height;

      const bl = Util.applyTransform([0, 0], inv);
      const br = Util.applyTransform([0, height], inv);
      const ul = Util.applyTransform([width, 0], inv);
      const ur = Util.applyTransform([width, height], inv);

      const x0 = Math.min(bl[0], br[0], ul[0], ur[0]);
      const y0 = Math.min(bl[1], br[1], ul[1], ur[1]);
      const x1 = Math.max(bl[0], br[0], ul[0], ur[0]);
      const y1 = Math.max(bl[1], br[1], ul[1], ur[1]);

      this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
    } else {
      // HACK to draw the gradient onto an infinite rectangle.
      // PDF gradients are drawn across the entire image while
      // Canvas only allows gradients to be drawn in a rectangle
      // The following bug should allow us to remove this.
      // https://bugzilla.mozilla.org/show_bug.cgi?id=664884

      this.ctx.fillRect(-1e10, -1e10, 2e10, 2e10);
    }

    this.compose(this.current.getClippedPathBoundingBox());
    this.restore();
  }

  // Images
  beginInlineImage() {
    unreachable("Should not call beginInlineImage");
  }

  beginImageData() {
    unreachable("Should not call beginImageData");
  }

  paintFormXObjectBegin(matrix, bbox) {
    if (!this.contentVisible) {
      return;
    }
    this.save();
    this.baseTransformStack.push(this.baseTransform);

    if (Array.isArray(matrix) && matrix.length === 6) {
      this.transform.apply(this, matrix);
    }

    this.baseTransform = this.ctx.mozCurrentTransform;

    if (bbox) {
      const width = bbox[2] - bbox[0];
      const height = bbox[3] - bbox[1];
      this.ctx.rect(bbox[0], bbox[1], width, height);
      this.current.updateRectMinMax(this.ctx.mozCurrentTransform, bbox);
      this.clip();
      this.endPath();
    }
  }

  paintFormXObjectEnd() {
    if (!this.contentVisible) {
      return;
    }
    this.restore();
    this.baseTransform = this.baseTransformStack.pop();
  }

  beginGroup(group) {
    if (!this.contentVisible) {
      return;
    }

    this.save();
    // If there's an active soft mask we don't want it enabled for the group, so
    // clear it out. The mask and suspended canvas will be restored in endGroup.
    if (this.inSMaskMode) {
      this.endSMaskMode();
      this.current.activeSMask = null;
    }

    const currentCtx = this.ctx;
    // TODO non-isolated groups - according to Rik at adobe non-isolated
    // group results aren't usually that different and they even have tools
    // that ignore this setting. Notes from Rik on implementing:
    // - When you encounter an transparency group, create a new canvas with
    // the dimensions of the bbox
    // - copy the content from the previous canvas to the new canvas
    // - draw as usual
    // - remove the backdrop alpha:
    // alphaNew = 1 - (1 - alpha)/(1 - alphaBackdrop) with 'alpha' the alpha
    // value of your transparency group and 'alphaBackdrop' the alpha of the
    // backdrop
    // - remove background color:
    // colorNew = color - alphaNew *colorBackdrop /(1 - alphaNew)
    if (!group.isolated) {
      info("TODO: Support non-isolated groups.");
    }

    // TODO knockout - supposedly possible with the clever use of compositing
    // modes.
    if (group.knockout) {
      warn("Knockout groups not supported.");
    }

    const currentTransform = currentCtx.mozCurrentTransform;
    if (group.matrix) {
      currentCtx.transform.apply(currentCtx, group.matrix);
    }
    if (!group.bbox) {
      throw new Error("Bounding box is required.");
    }

    // Based on the current transform figure out how big the bounding box
    // will actually be.
    let bounds = Util.getAxialAlignedBoundingBox(
      group.bbox,
      currentCtx.mozCurrentTransform
    );
    // Clip the bounding box to the current canvas.
    const canvasBounds = [
      0,
      0,
      currentCtx.canvas.width,
      currentCtx.canvas.height,
    ];
    bounds = Util.intersect(bounds, canvasBounds) || [0, 0, 0, 0];
    // Use ceil in case we're between sizes so we don't create canvas that is
    // too small and make the canvas at least 1x1 pixels.
    const offsetX = Math.floor(bounds[0]);
    const offsetY = Math.floor(bounds[1]);
    let drawnWidth = Math.max(Math.ceil(bounds[2]) - offsetX, 1);
    let drawnHeight = Math.max(Math.ceil(bounds[3]) - offsetY, 1);
    let scaleX = 1,
      scaleY = 1;
    if (drawnWidth > MAX_GROUP_SIZE) {
      scaleX = drawnWidth / MAX_GROUP_SIZE;
      drawnWidth = MAX_GROUP_SIZE;
    }
    if (drawnHeight > MAX_GROUP_SIZE) {
      scaleY = drawnHeight / MAX_GROUP_SIZE;
      drawnHeight = MAX_GROUP_SIZE;
    }

    this.current.startNewPathAndClipBox([0, 0, drawnWidth, drawnHeight]);

    let cacheId = "groupAt" + this.groupLevel;
    if (group.smask) {
      // Using two cache entries is case if masks are used one after another.
      cacheId += "_smask_" + (this.smaskCounter++ % 2);
    }
    const scratchCanvas = this.cachedCanvases.getCanvas(
      cacheId,
      drawnWidth,
      drawnHeight,
      /* trackTransform */ true
    );
    const groupCtx = scratchCanvas.context;

    // Since we created a new canvas that is just the size of the bounding box
    // we have to translate the group ctx.
    groupCtx.scale(1 / scaleX, 1 / scaleY);
    groupCtx.translate(-offsetX, -offsetY);
    groupCtx.transform.apply(groupCtx, currentTransform);

    if (group.smask) {
      // Saving state and cached mask to be used in setGState.
      this.smaskStack.push({
        canvas: scratchCanvas.canvas,
        context: groupCtx,
        offsetX,
        offsetY,
        scaleX,
        scaleY,
        subtype: group.smask.subtype,
        backdrop: group.smask.backdrop,
        transferMap: group.smask.transferMap || null,
        startTransformInverse: null, // used during suspend operation
      });
    } else {
      // Setup the current ctx so when the group is popped we draw it at the
      // right location.
      currentCtx.setTransform(1, 0, 0, 1, 0, 0);
      currentCtx.translate(offsetX, offsetY);
      currentCtx.scale(scaleX, scaleY);
      currentCtx.save();
    }
    // The transparency group inherits all off the current graphics state
    // except the blend mode, soft mask, and alpha constants.
    copyCtxState(currentCtx, groupCtx);
    this.ctx = groupCtx;
    this.setGState([
      ["BM", "source-over"],
      ["ca", 1],
      ["CA", 1],
    ]);
    this.groupStack.push(currentCtx);
    this.groupLevel++;
  }

  endGroup(group) {
    if (!this.contentVisible) {
      return;
    }
    this.groupLevel--;
    const groupCtx = this.ctx;
    const ctx = this.groupStack.pop();
    this.ctx = ctx;
    // Turn off image smoothing to avoid sub pixel interpolation which can
    // look kind of blurry for some pdfs.
    this.ctx.imageSmoothingEnabled = false;

    if (group.smask) {
      this.tempSMask = this.smaskStack.pop();
      this.restore();
    } else {
      this.ctx.restore();
      const currentMtx = this.ctx.mozCurrentTransform;
      this.restore();
      this.ctx.save();
      this.ctx.setTransform.apply(this.ctx, currentMtx);
      const dirtyBox = Util.getAxialAlignedBoundingBox(
        [0, 0, groupCtx.canvas.width, groupCtx.canvas.height],
        currentMtx
      );
      this.ctx.drawImage(groupCtx.canvas, 0, 0);
      this.ctx.restore();
      this.compose(dirtyBox);
    }
  }

  beginAnnotation(id, rect, transform, matrix, hasOwnCanvas) {
    // The annotations are drawn just after the page content.
    // The page content drawing can potentially have set a transform,
    // a clipping path, whatever...
    // So in order to have something clean, we restore the initial state.
    this.#restoreInitialState();
    resetCtxToDefault(this.ctx, this.foregroundColor);

    this.ctx.save();
    this.save();

    if (this.baseTransform) {
      this.ctx.setTransform.apply(this.ctx, this.baseTransform);
    }

    if (Array.isArray(rect) && rect.length === 4) {
      const width = rect[2] - rect[0];
      const height = rect[3] - rect[1];

      if (hasOwnCanvas && this.annotationCanvasMap) {
        transform = transform.slice();
        transform[4] -= rect[0];
        transform[5] -= rect[1];

        rect = rect.slice();
        rect[0] = rect[1] = 0;
        rect[2] = width;
        rect[3] = height;

        const [scaleX, scaleY] = Util.singularValueDecompose2dScale(
          this.ctx.mozCurrentTransform
        );
        const { viewportScale } = this;
        const canvasWidth = Math.ceil(
          width * this.outputScaleX * viewportScale
        );
        const canvasHeight = Math.ceil(
          height * this.outputScaleY * viewportScale
        );

        this.annotationCanvas = this.canvasFactory.create(
          canvasWidth,
          canvasHeight
        );
        const { canvas, context } = this.annotationCanvas;
        this.annotationCanvasMap.set(id, canvas);
        this.annotationCanvas.savedCtx = this.ctx;
        this.ctx = context;
        addContextCurrentTransform(this.ctx);
        this.ctx.setTransform(scaleX, 0, 0, -scaleY, 0, height * scaleY);

        resetCtxToDefault(this.ctx, this.foregroundColor);
      } else {
        resetCtxToDefault(this.ctx, this.foregroundColor);

        this.ctx.rect(rect[0], rect[1], width, height);
        this.ctx.clip();
        this.endPath();
      }
    }

    this.current = new CanvasExtraState(
      this.ctx.canvas.width,
      this.ctx.canvas.height
    );

    this.transform.apply(this, transform);
    this.transform.apply(this, matrix);
  }

  endAnnotation() {
    if (this.annotationCanvas) {
      this.ctx = this.annotationCanvas.savedCtx;
      delete this.annotationCanvas.savedCtx;
      delete this.annotationCanvas;
    }
  }

  paintImageMaskXObject(img) {
    if (!this.contentVisible) {
      return;
    }
    const count = img.count;
    img = this.getObject(img.data, img);
    img.count = count;

    const ctx = this.ctx;
    const glyph = this.processingType3;

    if (glyph) {
      if (glyph.compiled === undefined) {
        glyph.compiled = compileType3Glyph(img);
      }

      if (glyph.compiled) {
        glyph.compiled(ctx);
        return;
      }
    }
    const mask = this._createMaskCanvas(img);
    const maskCanvas = mask.canvas;

    ctx.save();
    // The mask is drawn with the transform applied. Reset the current
    // transform to draw to the identity.
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY);
    ctx.restore();
    this.compose();
  }

  paintImageMaskXObjectRepeat(
    img,
    scaleX,
    skewX = 0,
    skewY = 0,
    scaleY,
    positions
  ) {
    if (!this.contentVisible) {
      return;
    }

    img = this.getObject(img.data, img);

    const ctx = this.ctx;
    ctx.save();
    const currentTransform = ctx.mozCurrentTransform;
    ctx.transform(scaleX, skewX, skewY, scaleY, 0, 0);
    const mask = this._createMaskCanvas(img);

    ctx.setTransform(1, 0, 0, 1, 0, 0);
    for (let i = 0, ii = positions.length; i < ii; i += 2) {
      const trans = Util.transform(currentTransform, [
        scaleX,
        skewX,
        skewY,
        scaleY,
        positions[i],
        positions[i + 1],
      ]);

      const [x, y] = Util.applyTransform([0, 0], trans);
      ctx.drawImage(mask.canvas, x, y);
    }
    ctx.restore();
    this.compose();
  }

  paintImageMaskXObjectGroup(images) {
    if (!this.contentVisible) {
      return;
    }
    const ctx = this.ctx;

    const fillColor = this.current.fillColor;
    const isPatternFill = this.current.patternFill;

    for (const image of images) {
      const { data, width, height, transform } = image;

      const maskCanvas = this.cachedCanvases.getCanvas(
        "maskCanvas",
        width,
        height,
        /* trackTransform */ false
      );
      const maskCtx = maskCanvas.context;
      maskCtx.save();

      const img = this.getObject(data, image);
      putBinaryImageMask(maskCtx, img);

      maskCtx.globalCompositeOperation = "source-in";

      maskCtx.fillStyle = isPatternFill
        ? fillColor.getPattern(
            maskCtx,
            this,
            ctx.mozCurrentTransformInverse,
            PathType.FILL
          )
        : fillColor;
      maskCtx.fillRect(0, 0, width, height);

      maskCtx.restore();

      ctx.save();
      ctx.transform.apply(ctx, transform);
      ctx.scale(1, -1);
      drawImageAtIntegerCoords(
        ctx,
        maskCanvas.canvas,
        0,
        0,
        width,
        height,
        0,
        -1,
        1,
        1
      );
      ctx.restore();
    }
    this.compose();
  }

  paintImageXObject(objId) {
    if (!this.contentVisible) {
      return;
    }
    const imgData = this.getObject(objId);
    if (!imgData) {
      warn("Dependent image isn't ready yet");
      return;
    }

    this.paintInlineImageXObject(imgData);
  }

  paintImageXObjectRepeat(objId, scaleX, scaleY, positions) {
    if (!this.contentVisible) {
      return;
    }
    const imgData = this.getObject(objId);
    if (!imgData) {
      warn("Dependent image isn't ready yet");
      return;
    }

    const width = imgData.width;
    const height = imgData.height;
    const map = [];
    for (let i = 0, ii = positions.length; i < ii; i += 2) {
      map.push({
        transform: [scaleX, 0, 0, scaleY, positions[i], positions[i + 1]],
        x: 0,
        y: 0,
        w: width,
        h: height,
      });
    }
    this.paintInlineImageXObjectGroup(imgData, map);
  }

  paintInlineImageXObject(imgData) {
    if (!this.contentVisible) {
      return;
    }
    const width = imgData.width;
    const height = imgData.height;
    const ctx = this.ctx;

    this.save();
    // scale the image to the unit square
    ctx.scale(1 / width, -1 / height);

    let imgToPaint;
    // typeof check is needed due to node.js support, see issue #8489
    if (
      (typeof HTMLElement === "function" && imgData instanceof HTMLElement) ||
      !imgData.data
    ) {
      imgToPaint = imgData;
    } else {
      const tmpCanvas = this.cachedCanvases.getCanvas(
        "inlineImage",
        width,
        height,
        /* trackTransform */ false
      );
      const tmpCtx = tmpCanvas.context;
      putBinaryImageData(tmpCtx, imgData, this.current.transferMaps);
      imgToPaint = tmpCanvas.canvas;
    }

    const scaled = this._scaleImage(imgToPaint, ctx.mozCurrentTransformInverse);
    ctx.imageSmoothingEnabled = getImageSmoothingEnabled(
      ctx.mozCurrentTransform,
      imgData.interpolate
    );

    const [rWidth, rHeight] = drawImageAtIntegerCoords(
      ctx,
      scaled.img,
      0,
      0,
      scaled.paintWidth,
      scaled.paintHeight,
      0,
      -height,
      width,
      height
    );

    if (this.imageLayer) {
      const position = this.getCanvasPosition(0, -height);
      this.imageLayer.appendImage({
        imgData,
        left: position[0],
        top: position[1],
        width: rWidth,
        height: rHeight,
      });
    }
    this.compose();
    this.restore();
  }

  paintInlineImageXObjectGroup(imgData, map) {
    if (!this.contentVisible) {
      return;
    }
    const ctx = this.ctx;
    const w = imgData.width;
    const h = imgData.height;

    const tmpCanvas = this.cachedCanvases.getCanvas(
      "inlineImage",
      w,
      h,
      /* trackTransform */ false
    );
    const tmpCtx = tmpCanvas.context;
    putBinaryImageData(tmpCtx, imgData, this.current.transferMaps);

    for (let i = 0, ii = map.length; i < ii; i++) {
      const entry = map[i];
      ctx.save();
      ctx.transform.apply(ctx, entry.transform);
      ctx.scale(1, -1);
      drawImageAtIntegerCoords(
        ctx,
        tmpCanvas.canvas,
        entry.x,
        entry.y,
        entry.w,
        entry.h,
        0,
        -1,
        1,
        1
      );
      if (this.imageLayer) {
        const position = this.getCanvasPosition(entry.x, entry.y);
        this.imageLayer.appendImage({
          imgData,
          left: position[0],
          top: position[1],
          width: w,
          height: h,
        });
      }
      ctx.restore();
    }
    this.compose();
  }

  paintSolidColorImageMask() {
    if (!this.contentVisible) {
      return;
    }
    this.ctx.fillRect(0, 0, 1, 1);
    this.compose();
  }

  // Marked content

  markPoint(tag) {
    // TODO Marked content.
  }

  markPointProps(tag, properties) {
    // TODO Marked content.
  }

  beginMarkedContent(tag) {
    this.markedContentStack.push({
      visible: true,
    });
  }

  beginMarkedContentProps(tag, properties) {
    if (tag === "OC") {
      this.markedContentStack.push({
        visible: this.optionalContentConfig.isVisible(properties),
      });
    } else {
      this.markedContentStack.push({
        visible: true,
      });
    }
    this.contentVisible = this.isContentVisible();
  }

  endMarkedContent() {
    this.markedContentStack.pop();
    this.contentVisible = this.isContentVisible();
  }

  // Compatibility

  beginCompat() {
    // TODO ignore undefined operators (should we do that anyway?)
  }

  endCompat() {
    // TODO stop ignoring undefined operators
  }

  // Helper functions

  consumePath(clipBox) {
    const isEmpty = this.current.isEmptyClip();
    if (this.pendingClip) {
      this.current.updateClipFromPath();
    }
    if (!this.pendingClip) {
      this.compose(clipBox);
    }
    const ctx = this.ctx;
    if (this.pendingClip) {
      if (!isEmpty) {
        if (this.pendingClip === EO_CLIP) {
          ctx.clip("evenodd");
        } else {
          ctx.clip();
        }
      }
      this.pendingClip = null;
    }
    this.current.startNewPathAndClipBox(this.current.clipBox);
    ctx.beginPath();
  }

  getSinglePixelWidth() {
    if (!this._cachedGetSinglePixelWidth) {
      const m = this.ctx.mozCurrentTransform;
      if (m[1] === 0 && m[2] === 0) {
        // Fast path
        this._cachedGetSinglePixelWidth =
          1 / Math.min(Math.abs(m[0]), Math.abs(m[3]));
      } else {
        const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
        const normX = Math.hypot(m[0], m[2]);
        const normY = Math.hypot(m[1], m[3]);
        this._cachedGetSinglePixelWidth = Math.max(normX, normY) / absDet;
      }
    }
    return this._cachedGetSinglePixelWidth;
  }

  getScaleForStroking() {
    // A pixel has thicknessX = thicknessY = 1;
    // A transformed pixel is a parallelogram and the thicknesses
    // corresponds to the heights.
    // The goal of this function is to rescale before setting the
    // lineWidth in order to have both thicknesses greater or equal
    // to 1 after transform.
    if (!this._cachedScaleForStroking) {
      const { lineWidth } = this.current;
      const m = this.ctx.mozCurrentTransform;
      let scaleX, scaleY;

      if (m[1] === 0 && m[2] === 0) {
        // Fast path
        const normX = Math.abs(m[0]);
        const normY = Math.abs(m[3]);
        if (lineWidth === 0) {
          scaleX = 1 / normX;
          scaleY = 1 / normY;
        } else {
          const scaledXLineWidth = normX * lineWidth;
          const scaledYLineWidth = normY * lineWidth;
          scaleX = scaledXLineWidth < 1 ? 1 / scaledXLineWidth : 1;
          scaleY = scaledYLineWidth < 1 ? 1 / scaledYLineWidth : 1;
        }
      } else {
        // A pixel (base (x, y)) is transformed by M into a parallelogram:
        //  - its area is |det(M)|;
        //  - heightY (orthogonal to Mx) has a length: |det(M)| / norm(Mx);
        //  - heightX (orthogonal to My) has a length: |det(M)| / norm(My).
        // heightX and heightY are the thicknesses of the transformed pixel
        // and they must be both greater or equal to 1.
        const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
        const normX = Math.hypot(m[0], m[1]);
        const normY = Math.hypot(m[2], m[3]);
        if (lineWidth === 0) {
          scaleX = normY / absDet;
          scaleY = normX / absDet;
        } else {
          const baseArea = lineWidth * absDet;
          scaleX = normY > baseArea ? normY / baseArea : 1;
          scaleY = normX > baseArea ? normX / baseArea : 1;
        }
      }
      this._cachedScaleForStroking = [scaleX, scaleY];
    }
    return this._cachedScaleForStroking;
  }

  // Rescale before stroking in order to have a final lineWidth
  // with both thicknesses greater or equal to 1.
  rescaleAndStroke(saveRestore) {
    const { ctx } = this;
    const { lineWidth } = this.current;
    const [scaleX, scaleY] = this.getScaleForStroking();

    ctx.lineWidth = lineWidth || 1;

    if (scaleX === 1 && scaleY === 1) {
      ctx.stroke();
      return;
    }

    let savedMatrix, savedDashes, savedDashOffset;
    if (saveRestore) {
      savedMatrix = ctx.mozCurrentTransform.slice();
      savedDashes = ctx.getLineDash().slice();
      savedDashOffset = ctx.lineDashOffset;
    }

    ctx.scale(scaleX, scaleY);

    // How the dashed line is rendered depends on the current transform...
    // so we added a rescale to handle too thin lines and consequently
    // the way the line is dashed will be modified.
    // If scaleX === scaleY, the dashed lines will be rendered correctly
    // else we'll have some bugs (but only with too thin lines).
    // Here we take the max... why not taking the min... or something else.
    // Anyway, as said it's buggy when scaleX !== scaleY.
    const scale = Math.max(scaleX, scaleY);
    ctx.setLineDash(ctx.getLineDash().map(x => x / scale));
    ctx.lineDashOffset /= scale;

    ctx.stroke();

    if (saveRestore) {
      ctx.setTransform(...savedMatrix);
      ctx.setLineDash(savedDashes);
      ctx.lineDashOffset = savedDashOffset;
    }
  }

  getCanvasPosition(x, y) {
    const transform = this.ctx.mozCurrentTransform;
    return [
      transform[0] * x + transform[2] * y + transform[4],
      transform[1] * x + transform[3] * y + transform[5],
    ];
  }

  isContentVisible() {
    for (let i = this.markedContentStack.length - 1; i >= 0; i--) {
      if (!this.markedContentStack[i].visible) {
        return false;
      }
    }
    return true;
  }
}

for (const op in OPS) {
  if (CanvasGraphics.prototype[op] !== undefined) {
    CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
  }
}

export { CanvasGraphics };