From ba8c996623c66703d5b2b97cfa3850486a40d057 Mon Sep 17 00:00:00 2001
From: Calixte Denizet <calixte.denizet@gmail.com>
Date: Mon, 5 Jun 2023 12:15:41 +0200
Subject: [PATCH] [Editor] Guess font size and color from the AS of FreeText
 annotations

---
 src/core/annotation.js               |  16 ++-
 src/core/default_appearance.js       |  87 +++++++++++++++++
 test/unit/default_appearance_spec.js | 140 +++++++++++++++++++++++++++
 3 files changed, 238 insertions(+), 5 deletions(-)

diff --git a/src/core/annotation.js b/src/core/annotation.js
index 054487d57..7937479d4 100644
--- a/src/core/annotation.js
+++ b/src/core/annotation.js
@@ -48,6 +48,7 @@ import {
   createDefaultAppearance,
   FakeUnicodeFont,
   getPdfColor,
+  parseAppearanceStream,
   parseDefaultAppearance,
 } from "./default_appearance.js";
 import { Dict, isName, Name, Ref, RefSet } from "./primitives.js";
@@ -3545,20 +3546,25 @@ class FreeTextAnnotation extends MarkupAnnotation {
     const { xref } = params;
     this.data.annotationType = AnnotationType.FREETEXT;
     this.setDefaultAppearance(params);
-    if (!this.appearance && this._isOffscreenCanvasSupported) {
+    if (this.appearance) {
+      const { fontColor, fontSize } = parseAppearanceStream(this.appearance);
+      this.data.defaultAppearanceData.fontColor = fontColor;
+      this.data.defaultAppearanceData.fontSize = fontSize || 10;
+    } else if (this._isOffscreenCanvasSupported) {
       const strokeAlpha = params.dict.get("CA");
       const fakeUnicodeFont = new FakeUnicodeFont(xref, "sans-serif");
-      const fontData = this.data.defaultAppearanceData;
+      this.data.defaultAppearanceData.fontSize ||= 10;
+      const { fontColor, fontSize } = this.data.defaultAppearanceData;
       this.appearance = fakeUnicodeFont.createAppearance(
         this._contents.str,
         this.rectangle,
         this.rotation,
-        fontData.fontSize || 10,
-        fontData.fontColor,
+        fontSize,
+        fontColor,
         strokeAlpha
       );
       this._streams.push(this.appearance, FakeUnicodeFont.toUnicodeStream);
-    } else if (!this._isOffscreenCanvasSupported) {
+    } else {
       warn(
         "FreeTextAnnotation: OffscreenCanvas is not supported, annotation may not render correctly."
       );
diff --git a/src/core/default_appearance.js b/src/core/default_appearance.js
index e886d3184..2816fc90e 100644
--- a/src/core/default_appearance.js
+++ b/src/core/default_appearance.js
@@ -87,6 +87,92 @@ function parseDefaultAppearance(str) {
   return new DefaultAppearanceEvaluator(str).parse();
 }
 
+class AppearanceStreamEvaluator extends EvaluatorPreprocessor {
+  constructor(stream) {
+    super(stream);
+    this.stream = stream;
+  }
+
+  parse() {
+    const operation = {
+      fn: 0,
+      args: [],
+    };
+    let result = {
+      scaleFactor: 1,
+      fontSize: 0,
+      fontName: "",
+      fontColor: /* black = */ new Uint8ClampedArray(3),
+    };
+    let breakLoop = false;
+    const stack = [];
+
+    try {
+      while (true) {
+        operation.args.length = 0; // Ensure that `args` it's always reset.
+
+        if (breakLoop || !this.read(operation)) {
+          break;
+        }
+        const { fn, args } = operation;
+
+        switch (fn | 0) {
+          case OPS.save:
+            stack.push({
+              scaleFactor: result.scaleFactor,
+              fontSize: result.fontSize,
+              fontName: result.fontName,
+              fontColor: result.fontColor.slice(),
+            });
+            break;
+          case OPS.restore:
+            result = stack.pop() || result;
+            break;
+          case OPS.setTextMatrix:
+            result.scaleFactor *= Math.hypot(args[0], args[1]);
+            break;
+          case OPS.setFont:
+            const [fontName, fontSize] = args;
+            if (fontName instanceof Name) {
+              result.fontName = fontName.name;
+            }
+            if (typeof fontSize === "number" && fontSize > 0) {
+              result.fontSize = fontSize * result.scaleFactor;
+            }
+            break;
+          case OPS.setFillRGBColor:
+            ColorSpace.singletons.rgb.getRgbItem(args, 0, result.fontColor, 0);
+            break;
+          case OPS.setFillGray:
+            ColorSpace.singletons.gray.getRgbItem(args, 0, result.fontColor, 0);
+            break;
+          case OPS.setFillColorSpace:
+            ColorSpace.singletons.cmyk.getRgbItem(args, 0, result.fontColor, 0);
+            break;
+          case OPS.showText:
+          case OPS.showSpacedText:
+          case OPS.nextLineShowText:
+          case OPS.nextLineSetSpacingShowText:
+            breakLoop = true;
+            break;
+        }
+      }
+    } catch (reason) {
+      warn(`parseAppearanceStream - ignoring errors: "${reason}".`);
+    }
+    this.stream.reset();
+    delete result.scaleFactor;
+
+    return result;
+  }
+}
+
+// Parse appearance stream to extract font and color information.
+// It returns the font properties used to render the first text object.
+function parseAppearanceStream(stream) {
+  return new AppearanceStreamEvaluator(stream).parse();
+}
+
 function getPdfColor(color, isFill) {
   if (color[0] === color[1] && color[1] === color[2]) {
     const gray = color[0] / 255;
@@ -368,5 +454,6 @@ export {
   createDefaultAppearance,
   FakeUnicodeFont,
   getPdfColor,
+  parseAppearanceStream,
   parseDefaultAppearance,
 };
diff --git a/test/unit/default_appearance_spec.js b/test/unit/default_appearance_spec.js
index 7bd422736..53b49ed5b 100644
--- a/test/unit/default_appearance_spec.js
+++ b/test/unit/default_appearance_spec.js
@@ -15,8 +15,10 @@
 
 import {
   createDefaultAppearance,
+  parseAppearanceStream,
   parseDefaultAppearance,
 } from "../../src/core/default_appearance.js";
+import { StringStream } from "../../src/core/stream.js";
 
 describe("Default appearance", function () {
   describe("parseDefaultAppearance and createDefaultAppearance", function () {
@@ -50,4 +52,142 @@ describe("Default appearance", function () {
       });
     });
   });
+
+  describe("parseAppearanceStream", () => {
+    it("should parse a FreeText (from Acrobat) appearance", () => {
+      const appearance = new StringStream(`
+      0 w
+      46.5 621.0552 156.389 18.969 re
+      n
+      q
+      1 0 0 1 0 0 cm
+      46.5 621.0552 156.389 18.969 re
+      W
+      n
+      0 g
+      1 w
+      BT
+      /Helv 14 Tf
+      0.419998 0.850006 0.160004 rg
+      46.5 626.77 Td
+      (Hello ) Tj
+      35.793 0 Td
+      (World ) Tj
+      40.448 0 Td
+      (from ) Tj
+      31.89 0 Td
+      (Acrobat) Tj
+      ET
+      Q`);
+      const result = {
+        fontSize: 14,
+        fontName: "Helv",
+        fontColor: new Uint8ClampedArray([107, 217, 41]),
+      };
+      expect(parseAppearanceStream(appearance)).toEqual(result);
+      expect(appearance.pos).toEqual(0);
+    });
+
+    it("should parse a FreeText (from Firefox) appearance", () => {
+      const appearance = new StringStream(`
+      q
+      0 0 203.7 28.3 re W n
+      BT
+      1 0 0 1 0 34.6 Tm 0 Tc 0.93 0.17 0.44 rg
+      /Helv 18 Tf
+      0 -24.3 Td (Hello World From Firefox) Tj
+      ET
+      Q`);
+      const result = {
+        fontSize: 18,
+        fontName: "Helv",
+        fontColor: new Uint8ClampedArray([237, 43, 112]),
+      };
+      expect(parseAppearanceStream(appearance)).toEqual(result);
+      expect(appearance.pos).toEqual(0);
+    });
+
+    it("should parse a FreeText (from Preview) appearance", () => {
+      const appearance = new StringStream(`
+      q Q q 2.128482 2.128482 247.84 26 re W n /Cs1 cs 0.52799 0.3071 0.99498 sc
+      q 1 0 0 -1 -108.3364 459.8485 cm BT 22.00539 0 0 -22.00539 110.5449 452.72
+      Tm /TT1 1 Tf [ (H) -0.2 (e) -0.2 (l) -0.2 (l) -0.2 (o) -0.2 ( ) 0.2 (W) 17.7
+      (o) -0.2 (rl) -0.2 (d) -0.2 ( ) 0.2 (f) 0.2 (ro) -0.2 (m ) 0.2 (Pre) -0.2
+      (vi) -0.2 (e) -0.2 (w) ] TJ ET Q Q`);
+      const result = {
+        fontSize: 22.00539,
+        fontName: "TT1",
+        fontColor: new Uint8ClampedArray([0, 0, 0]),
+      };
+      expect(parseAppearanceStream(appearance)).toEqual(result);
+      expect(appearance.pos).toEqual(0);
+    });
+
+    it("should parse a FreeText (from Edge) appearance", () => {
+      const appearance = new StringStream(`
+      q
+      0 0 292.5 18.75 re W n
+      BT
+      0 Tc
+      0.0627451 0.486275 0.0627451 rg
+      0 3.8175 Td
+      /Helv 16.5 Tf
+      (Hello World from Edge without Acrobat) Tj
+      ET
+      Q`);
+      const result = {
+        fontSize: 16.5,
+        fontName: "Helv",
+        fontColor: new Uint8ClampedArray([16, 124, 16]),
+      };
+      expect(parseAppearanceStream(appearance)).toEqual(result);
+      expect(appearance.pos).toEqual(0);
+    });
+
+    it("should parse a FreeText (from Foxit) appearance", () => {
+      const appearance = new StringStream(`
+      q
+      /Tx BMC
+      0 -22.333 197.667 22.333 re
+      W
+      n
+      BT
+       0.584314 0.247059 0.235294 rg
+      0 -18.1 Td
+      /FXF0 20 Tf
+      (Hello World from Foxit) Tj
+      ET
+      EMC
+      Q`);
+      const result = {
+        fontSize: 20,
+        fontName: "FXF0",
+        fontColor: new Uint8ClampedArray([149, 63, 60]),
+      };
+      expect(parseAppearanceStream(appearance)).toEqual(result);
+      expect(appearance.pos).toEqual(0);
+    });
+
+    it("should parse a FreeText (from Okular) appearance", () => {
+      const appearance = new StringStream(`
+      q
+      0.00 0.00 172.65 41.46 re W n
+      0.00000 0.33333 0.49804 rg
+      BT 1 0 0 1 0.00 41.46 Tm
+      /Invalid_font 18.00 Tf
+      0.00 -18.00 Td
+      (Hello World from) Tj
+      /Invalid_font 18.00 Tf
+      0.00 -18.00 Td
+      (Okular) Tj
+      ET Q`);
+      const result = {
+        fontSize: 18,
+        fontName: "Invalid_font",
+        fontColor: new Uint8ClampedArray([0, 85, 127]),
+      };
+      expect(parseAppearanceStream(appearance)).toEqual(result);
+      expect(appearance.pos).toEqual(0);
+    });
+  });
 });