diff --git a/src/display/canvas.js b/src/display/canvas.js index 219aa8050..d121a8a9d 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -60,6 +60,136 @@ const FULL_CHUNK_HEIGHT = 16; // 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 the context doesn't expose a `mozCurrentTransform`, add a JS based one. if (ctx.mozCurrentTransform) { @@ -479,7 +609,6 @@ class CanvasExtraState { this.strokeAlpha = 1; this.lineWidth = 1; this.activeSMask = null; - this.resumeSMaskCtx = null; // nonclonable field (see the save method below) this.transferMaps = null; } @@ -816,7 +945,11 @@ function genericComposeSMask( height, subtype, backdrop, - transferMap + transferMap, + layerOffsetX, + layerOffsetY, + maskOffsetX, + maskOffsetY ) { const hasBackdrop = !!backdrop; const r0 = hasBackdrop ? backdrop[0] : 0; @@ -835,41 +968,52 @@ function genericComposeSMask( 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(0, row, width, chunkHeight); - const layerData = layerCtx.getImageData(0, row, width, chunkHeight); + 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); - maskCtx.putImageData(layerData, 0, row); + layerCtx.putImageData(layerData, layerOffsetX, row + layerOffsetY); } } -function composeSMask(ctx, smask, layerCtx) { - const mask = smask.canvas; - const maskCtx = smask.context; - - ctx.setTransform( - smask.scaleX, - 0, - 0, - smask.scaleY, +function composeSMask(ctx, smask, layerCtx, layerBox) { + const layerOffsetX = layerBox[0]; + const layerOffsetY = layerBox[1]; + const layerWidth = layerBox[2] - layerOffsetX; + const layerHeight = layerBox[3] - layerOffsetY; + genericComposeSMask( + smask.context, + layerCtx, + layerWidth, + layerHeight, + smask.subtype, + smask.backdrop, + smask.transferMap, + layerOffsetX, + layerOffsetY, smask.offsetX, smask.offsetY ); - - genericComposeSMask( - maskCtx, - layerCtx, - mask.width, - mask.height, - smask.subtype, - smask.backdrop, - smask.transferMap - ); - ctx.drawImage(mask, 0, 0); + 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) { @@ -927,6 +1071,7 @@ class CanvasGraphics { this.smaskStack = []; this.smaskCounter = 0; this.tempSMask = null; + this.suspendedCtx = null; this.contentVisible = true; this.markedContentStack = []; this.optionalContentConfig = optionalContentConfig; @@ -1319,25 +1464,9 @@ class CanvasGraphics { this.ctx.globalCompositeOperation = value; break; case "SMask": - if (this.current.activeSMask) { - // If SMask is currrenly used, it needs to be suspended or - // finished. Suspend only makes sense when at least one save() - // was performed and state needs to be reverted on restore(). - if ( - this.stateStack.length > 0 && - this.stateStack[this.stateStack.length - 1].activeSMask === - this.current.activeSMask - ) { - this.suspendSMaskGroup(); - } else { - this.endSMaskGroup(); - } - } this.current.activeSMask = value ? this.tempSMask : null; - if (this.current.activeSMask) { - this.beginSMaskGroup(); - } this.tempSMask = null; + this.checkSMaskState(); break; case "TR": this.current.transferMaps = value; @@ -1345,10 +1474,31 @@ class CanvasGraphics { } } - beginSMaskGroup() { - const activeSMask = this.current.activeSMask; - const drawnWidth = activeSMask.canvas.width; - const drawnHeight = activeSMask.canvas.height; + checkSMaskState() { + const inSMaskMode = !!this.suspendedCtx; + 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.suspendedCtx) { + 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, @@ -1356,84 +1506,51 @@ class CanvasGraphics { drawnHeight, 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); - const currentCtx = this.ctx; - const currentTransform = currentCtx.mozCurrentTransform; - this.ctx.save(); - - const groupCtx = scratchCanvas.context; - groupCtx.scale(1 / activeSMask.scaleX, 1 / activeSMask.scaleY); - groupCtx.translate(-activeSMask.offsetX, -activeSMask.offsetY); - groupCtx.transform.apply(groupCtx, currentTransform); - - activeSMask.startTransformInverse = groupCtx.mozCurrentTransformInverse; - - copyCtxState(currentCtx, groupCtx); - this.ctx = groupCtx; this.setGState([ ["BM", "source-over"], ["ca", 1], ["CA", 1], ]); - this.groupStack.push(currentCtx); - this.groupLevel++; } - suspendSMaskGroup() { - // Similar to endSMaskGroup, the intermediate canvas has to be composed - // and future ctx state restored. - const groupCtx = this.ctx; - this.groupLevel--; - this.ctx = this.groupStack.pop(); + endSMaskMode() { + if (!this.suspendedCtx) { + 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; - composeSMask(this.ctx, this.current.activeSMask, groupCtx); + this.current.activeSMask = null; + this.suspendedCtx = null; + } + + compose(dirtyBox) { + if (!this.current.activeSMask) { + return; + } + if (!dirtyBox) { + dirtyBox = [0, 0, this.ctx.canvas.width, this.ctx.canvas.height]; + } + 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(); - this.ctx.save(); // save is needed since SMask will be resumed. - copyCtxState(groupCtx, this.ctx); - - // Saving state for resuming. - this.current.resumeSMaskCtx = groupCtx; - // Transform was changed in the SMask canvas, reflecting this change on - // this.ctx. - const deltaTransform = Util.transform( - this.current.activeSMask.startTransformInverse, - groupCtx.mozCurrentTransform - ); - this.ctx.transform.apply(this.ctx, deltaTransform); - - // SMask was composed, the results at the groupCtx can be cleared. - groupCtx.save(); - groupCtx.setTransform(1, 0, 0, 1, 0, 0); - groupCtx.clearRect(0, 0, groupCtx.canvas.width, groupCtx.canvas.height); - groupCtx.restore(); - } - - resumeSMaskGroup() { - // Resuming state saved by suspendSMaskGroup. We don't need to restore - // any groupCtx state since restore() command (the only caller) will do - // that for us. See also beginSMaskGroup. - const groupCtx = this.current.resumeSMaskCtx; - const currentCtx = this.ctx; - this.ctx = groupCtx; - this.groupStack.push(currentCtx); - this.groupLevel++; - } - - endSMaskGroup() { - const groupCtx = this.ctx; - this.groupLevel--; - this.ctx = this.groupStack.pop(); - - composeSMask(this.ctx, this.current.activeSMask, groupCtx); - this.ctx.restore(); - copyCtxState(groupCtx, this.ctx); - // Transform was changed in the SMask canvas, reflecting this change on - // this.ctx. - const deltaTransform = Util.transform( - this.current.activeSMask.startTransformInverse, - groupCtx.mozCurrentTransform - ); - this.ctx.transform.apply(this.ctx, deltaTransform); } save() { @@ -1441,36 +1558,22 @@ class CanvasGraphics { const old = this.current; this.stateStack.push(old); this.current = old.clone(); - this.current.resumeSMaskCtx = null; } restore() { - // SMask was suspended, we just need to resume it. - if (this.current.resumeSMaskCtx) { - this.resumeSMaskGroup(); - } - // SMask has to be finished once there is no states that are using the - // same SMask. - if ( - this.current.activeSMask !== null && - (this.stateStack.length === 0 || - this.stateStack[this.stateStack.length - 1].activeSMask !== - this.current.activeSMask) - ) { - this.endSMaskGroup(); + if (this.stateStack.length === 0 && this.current.activeSMask) { + this.endSMaskMode(); } if (this.stateStack.length !== 0) { this.current = this.stateStack.pop(); this.ctx.restore(); + this.checkSMaskState(); // Ensure that the clipping path is reset (fixes issue6413.pdf). this.pendingClip = null; this._cachedGetSinglePixelWidth = null; - } else { - // We've finished all the SMask groups, reflect that in our state. - this.current.activeSMask = null; } } @@ -2092,6 +2195,7 @@ class CanvasGraphics { current.x += x * textHScale; } ctx.restore(); + this.compose(); return undefined; } @@ -2256,6 +2360,7 @@ class CanvasGraphics { ); const inv = ctx.mozCurrentTransformInverse; + let dirtyBox = [0, 0, ctx.canvas.width, ctx.canvas.height]; if (inv) { const canvas = ctx.canvas; const width = canvas.width; @@ -2270,6 +2375,10 @@ class CanvasGraphics { 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]); + dirtyBox = Util.getAxialAlignedBoundingBox( + [x0, y0, x1, y1], + ctx.mozCurrentTransform + ); this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0); } else { @@ -2282,6 +2391,7 @@ class CanvasGraphics { this.ctx.fillRect(-1e10, -1e10, 2e10, 2e10); } + this.compose(dirtyBox); this.restore(); } @@ -2330,6 +2440,14 @@ class CanvasGraphics { } 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. + const suspendedCtx = this.suspendedCtx; + if (this.current.activeSMask) { + this.suspendedCtx = null; + 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 @@ -2432,6 +2550,7 @@ class CanvasGraphics { 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. @@ -2442,11 +2561,11 @@ class CanvasGraphics { ["ca", 1], ["CA", 1], ]); - this.groupStack.push(currentCtx); + this.groupStack.push({ + ctx: currentCtx, + suspendedCtx, + }); this.groupLevel++; - - // Resetting mask state, masks will be applied on restore of the group. - this.current.activeSMask = null; } endGroup(group) { @@ -2455,17 +2574,33 @@ class CanvasGraphics { } this.groupLevel--; const groupCtx = this.ctx; - this.ctx = this.groupStack.pop(); + const { ctx, suspendedCtx } = 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 (suspendedCtx) { + this.suspendedCtx = suspendedCtx; + } + 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); } - this.restore(); } beginAnnotations() { @@ -2531,6 +2666,7 @@ class CanvasGraphics { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY); ctx.restore(); + this.compose(); } paintImageMaskXObjectRepeat( @@ -2565,6 +2701,7 @@ class CanvasGraphics { ctx.drawImage(mask.canvas, x, y); } ctx.restore(); + this.compose(); } paintImageMaskXObjectGroup(images) { @@ -2610,6 +2747,7 @@ class CanvasGraphics { ctx.drawImage(maskCanvas.canvas, 0, 0, width, height, 0, -1, 1, 1); ctx.restore(); } + this.compose(); } paintImageXObject(objId) { @@ -2711,6 +2849,7 @@ class CanvasGraphics { height: height / ctx.mozCurrentTransformInverse[3], }); } + this.compose(); this.restore(); } @@ -2754,6 +2893,7 @@ class CanvasGraphics { } ctx.restore(); } + this.compose(); } paintSolidColorImageMask() { @@ -2761,6 +2901,7 @@ class CanvasGraphics { return; } this.ctx.fillRect(0, 0, 1, 1); + this.compose(); } // Marked content @@ -2810,6 +2951,9 @@ class CanvasGraphics { // Helper functions consumePath() { + if (!this.pendingClip) { + this.compose(); + } const ctx = this.ctx; if (this.pendingClip) { if (this.pendingClip === EO_CLIP) { diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 8afb88053..6434d187d 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -16,6 +16,7 @@ !issue2391-1.pdf !issue2391-2.pdf !issue14046.pdf +!issue7891_bc1.pdf !issue3214.pdf !issue4665.pdf !issue4684.pdf @@ -105,6 +106,7 @@ !bug1057544.pdf !issue11150_reduced.pdf !issue6127.pdf +!issue7891_bc0.pdf !issue11242_reduced.pdf !issue11279.pdf !issue11362.pdf @@ -244,6 +246,7 @@ !issue2931.pdf !issue3323.pdf !issue4304.pdf +!issue9017_reduced.pdf !issue4379.pdf !issue4550.pdf !issue13316_reduced.pdf @@ -378,6 +381,7 @@ !issue5044.pdf !issue1512r.pdf !issue2128r.pdf +!bug1703683_page2_reduced.pdf !issue5540.pdf !issue5549.pdf !visibility_expressions.pdf @@ -432,6 +436,7 @@ !annotation-freetext.pdf !annotation-line.pdf !evaljs.pdf +!issue12798_page1_reduced.pdf !annotation-line-without-appearance.pdf !bug1669099.pdf !annotation-square-circle.pdf diff --git a/test/pdfs/bug1703683_page2_reduced.pdf b/test/pdfs/bug1703683_page2_reduced.pdf new file mode 100644 index 000000000..48a1ffc0a Binary files /dev/null and b/test/pdfs/bug1703683_page2_reduced.pdf differ diff --git a/test/pdfs/issue12798_page1_reduced.pdf b/test/pdfs/issue12798_page1_reduced.pdf new file mode 100644 index 000000000..79d374bc6 Binary files /dev/null and b/test/pdfs/issue12798_page1_reduced.pdf differ diff --git a/test/pdfs/issue7891_bc0.pdf b/test/pdfs/issue7891_bc0.pdf new file mode 100644 index 000000000..0221e1f3b Binary files /dev/null and b/test/pdfs/issue7891_bc0.pdf differ diff --git a/test/pdfs/issue7891_bc1.pdf b/test/pdfs/issue7891_bc1.pdf new file mode 100644 index 000000000..460335ea9 Binary files /dev/null and b/test/pdfs/issue7891_bc1.pdf differ diff --git a/test/pdfs/issue9017_reduced.pdf b/test/pdfs/issue9017_reduced.pdf new file mode 100644 index 000000000..b8ac7cd12 Binary files /dev/null and b/test/pdfs/issue9017_reduced.pdf differ diff --git a/test/test_manifest.json b/test/test_manifest.json index 2c6007b6a..42798b1dd 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -530,6 +530,12 @@ "rounds": 1, "type": "eq" }, + { "id": "issue12798_page1_reduced", + "file": "pdfs/issue12798_page1_reduced.pdf", + "md5": "f4c3e91c181b510929ade67c1e34c5c5", + "rounds": 1, + "type": "eq" + }, { "id": "hmm-pdf", "file": "pdfs/hmm.pdf", "md5": "e08467e60101ee5f4a59716e86db6dc9", @@ -769,6 +775,12 @@ "lastPage": 1, "type": "eq" }, + { "id": "bug1703683_page2_reduced", + "file": "pdfs/bug1703683_page2_reduced.pdf", + "md5": "2b6d5d617438cf72c76c25f46ec6ad75", + "rounds": 1, + "type": "eq" + }, { "id": "issue6707", "file": "pdfs/issue6707.pdf", "md5": "068ceaec23d265b1d38dfa6ab279f017", @@ -883,6 +895,12 @@ "type": "eq", "annotations": true }, + { "id": "issue9017_reduced", + "file": "pdfs/issue9017_reduced.pdf", + "md5": "8b45b3ea91778d6d98f407620d645f48", + "rounds": 1, + "type": "eq" + }, { "id": "issue4934", "file": "pdfs/issue4934.pdf", "md5": "6099da44f677702ae65a648b51a2226d", @@ -3442,6 +3460,12 @@ "link": true, "type": "eq" }, + { "id": "issue7891_bc0", + "file": "pdfs/issue7891_bc0.pdf", + "md5": "744a22244a4e4708b7f1691eec155fc8", + "rounds": 1, + "type": "eq" + }, { "id": "issue6165", "file": "pdfs/issue6165.pdf", @@ -4529,6 +4553,12 @@ "link": true, "type": "load" }, + { "id": "issue7891_bc1", + "file": "pdfs/issue7891_bc1.pdf", + "md5": "86b1796da7dad09f93ce68a8ad495a24", + "rounds": 1, + "type": "eq" + }, { "id": "issue3062", "file": "pdfs/issue3062.pdf", "md5": "206715f1258f0e117df4180d98dd4d68",