Repository: mozilla/pdf.js
Pull request #19710: Introduce `Math.sumPrecise` usage in the code-base
Author: login: Snuffleupagus
Labels: name: core
name: test
Created: 2025-03-23T08:42:46Z | Merged: 2025-03-23T12:34:29Z

This is a new JavaScript feature that makes it easy to compute the sum of list of values; see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sumPrecise

This allows us to remove most cases of `Array.prototype.reduce`, which helps improve readability since that (in my opinion) often isn't the most intuitive code.


Diff:
diff --git a/src/core/annotation.js b/src/core/annotation.js
index 0eee92ed53cd3..5dfbb34006b81 100644
--- a/src/core/annotation.js
+++ b/src/core/annotation.js
@@ -2572,11 +2572,7 @@ class WidgetAnnotation extends Annotation {
   }
 
   _getTextWidth(text, font) {
-    return (
-      font
-        .charsToGlyphs(text)
-        .reduce((width, glyph) => width + glyph.width, 0) / 1000
-    );
+    return Math.sumPrecise(font.charsToGlyphs(text).map(g => g.width)) / 1000;
   }
 
   _computeFontSize(height, width, text, font, lineCount) {
diff --git a/src/core/crypto.js b/src/core/crypto.js
index a6cc5e8bf736a..cfff2b8db5df4 100644
--- a/src/core/crypto.js
+++ b/src/core/crypto.js
@@ -720,7 +720,7 @@ class PDF20 extends PDFBase {
       // The number is e0 + 256 * e1 + 256^2 * e2... and 256 % 3 === 1, hence
       // the powers of 256 are === 1 modulo 3 and finally the number modulo 3
       // is equal to the remainder modulo 3 of the sum of the e_n.
-      const remainder = e.slice(0, 16).reduce((a, b) => a + b, 0) % 3;
+      const remainder = Math.sumPrecise(e.slice(0, 16)) % 3;
       if (remainder === 0) {
         k = calculateSHA256(e, 0, e.length);
       } else if (remainder === 1) {
diff --git a/src/core/glyf.js b/src/core/glyf.js
index d5ddb330653ea..06bde6482a45d 100644
--- a/src/core/glyf.js
+++ b/src/core/glyf.js
@@ -81,11 +81,10 @@ class GlyfTable {
   }
 
   getSize() {
-    return this.glyphs.reduce((a, g) => {
-      const size = g.getSize();
+    return Math.sumPrecise(
       // Round to next multiple of 4 if needed.
-      return a + ((size + 3) & ~3);
-    }, 0);
+      this.glyphs.map(g => (g.getSize() + 3) & ~3)
+    );
   }
 
   write() {
@@ -169,7 +168,7 @@ class Glyph {
     }
     const size = this.simple
       ? this.simple.getSize()
-      : this.composites.reduce((a, c) => a + c.getSize(), 0);
+      : Math.sumPrecise(this.composites.map(c => c.getSize()));
     return this.header.getSize() + size;
   }
 
diff --git a/src/core/writer.js b/src/core/writer.js
index cdb43d6bd9b89..bdeb8d0d6f6c9 100644
--- a/src/core/writer.js
+++ b/src/core/writer.js
@@ -184,7 +184,7 @@ function computeMD5(filesize, xrefInfo) {
     filesize.toString(),
     ...Object.values(xrefInfo.info),
   ];
-  const md5BufferLen = md5Buffer.reduce((a, str) => a + str.length, 0);
+  const md5BufferLen = Math.sumPrecise(md5Buffer.map(str => str.length));
 
   const array = new Uint8Array(md5BufferLen);
   let offset = 0;
@@ -352,7 +352,7 @@ async function getXRefStreamTable(
   newXref.set("W", sizes);
   computeIDs(baseOffset, xrefInfo, newXref);
 
-  const structSize = sizes.reduce((a, x) => a + x, 0);
+  const structSize = Math.sumPrecise(sizes);
   const data = new Uint8Array(structSize * xrefTableData.length);
   const stream = new Stream(data);
   stream.dict = newXref;
@@ -467,10 +467,8 @@ async function incrementalUpdate({
     ? getXRefStreamTable(xrefInfo, baseOffset, newRefs, newXref, buffer)
     : getXRefTable(xrefInfo, baseOffset, newRefs, newXref, buffer));
 
-  const totalLength = buffer.reduce(
-    (a, str) => a + str.length,
-    originalData.length
-  );
+  const totalLength =
+    originalData.length + Math.sumPrecise(buffer.map(str => str.length));
   const array = new Uint8Array(totalLength);
 
   // Original data
diff --git a/src/core/xfa/html_utils.js b/src/core/xfa/html_utils.js
index 71aaf83c127ca..615505ec35403 100644
--- a/src/core/xfa/html_utils.js
+++ b/src/core/xfa/html_utils.js
@@ -86,14 +86,15 @@ const converters = {
       const colSpan = node.colSpan;
       let w;
       if (colSpan === -1) {
-        w = extra.columnWidths
-          .slice(extra.currentColumn)
-          .reduce((a, x) => a + x, 0);
+        w = Math.sumPrecise(extra.columnWidths.slice(extra.currentColumn));
         extra.currentColumn = 0;
       } else {
-        w = extra.columnWidths
-          .slice(extra.currentColumn, extra.currentColumn + colSpan)
-          .reduce((a, x) => a + x, 0);
+        w = Math.sumPrecise(
+          extra.columnWidths.slice(
+            extra.currentColumn,
+            extra.currentColumn + colSpan
+          )
+        );
         extra.currentColumn =
           (extra.currentColumn + node.colSpan) % extra.columnWidths.length;
       }
@@ -328,13 +329,14 @@ function fixDimensions(node) {
     const colSpan = node.colSpan;
     let width;
     if (colSpan === -1) {
-      width = extra.columnWidths
-        .slice(extra.currentColumn)
-        .reduce((a, w) => a + w, 0);
+      width = Math.sumPrecise(extra.columnWidths.slice(extra.currentColumn));
     } else {
-      width = extra.columnWidths
-        .slice(extra.currentColumn, extra.currentColumn + colSpan)
-        .reduce((a, w) => a + w, 0);
+      width = Math.sumPrecise(
+        extra.columnWidths.slice(
+          extra.currentColumn,
+          extra.currentColumn + colSpan
+        )
+      );
     }
     if (!isNaN(width)) {
       node.w = width;
@@ -348,7 +350,7 @@ function fixDimensions(node) {
 
   if (node.layout === "table") {
     if (node.w === "" && Array.isArray(node.columnWidths)) {
-      node.w = node.columnWidths.reduce((a, x) => a + x, 0);
+      node.w = Math.sumPrecise(node.columnWidths);
     }
   }
 }
diff --git a/src/core/xfa/layout.js b/src/core/xfa/layout.js
index a7cdfd9b0427a..ce5142e6f21e8 100644
--- a/src/core/xfa/layout.js
+++ b/src/core/xfa/layout.js
@@ -183,9 +183,9 @@ function getAvailableSpace(node) {
       };
     case "rl-row":
     case "row":
-      const width = node[$extra].columnWidths
-        .slice(node[$extra].currentColumn)
-        .reduce((a, x) => a + x);
+      const width = Math.sumPrecise(
+        node[$extra].columnWidths.slice(node[$extra].currentColumn)
+      );
       return { width, height: availableSpace.height - marginH };
     case "table":
     case "tb":
diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js
index e17b5c3486a04..cb37fd13134c2 100644
--- a/src/display/editor/freetext.js
+++ b/src/display/editor/freetext.js
@@ -718,7 +718,7 @@ class FreeTextEditor extends AnnotationEditor {
 
     // Set the caret at the right position.
     const newRange = new Range();
-    let beforeLength = bufferBefore.reduce((acc, line) => acc + line.length, 0);
+    let beforeLength = Math.sumPrecise(bufferBefore.map(line => line.length));
     for (const { firstChild } of this.editorDiv.childNodes) {
       // Each child is either a div with a text node or a br element.
       if (firstChild.nodeType === Node.TEXT_NODE) {
diff --git a/src/shared/util.js b/src/shared/util.js
index 936cd9ebfc866..b36a7383a7608 100644
--- a/src/shared/util.js
+++ b/src/shared/util.js
@@ -1130,6 +1130,16 @@ if (
   };
 }
 
+// TODO: Remove this once the `javascript.options.experimental.math_sumprecise`
+//       preference is removed from Firefox.
+if (typeof Math.sumPrecise !== "function") {
+  // Note that this isn't a "proper" polyfill, but since we're only using it to
+  // replace `Array.prototype.reduce()` invocations it should be fine.
+  Math.sumPrecise = function (numbers) {
+    return numbers.reduce((a, b) => a + b, 0);
+  };
+}
+
 if (
   typeof PDFJSDev !== "undefined" &&
   !PDFJSDev.test("SKIP_BABEL") &&
diff --git a/test/unit/pdf_find_controller_spec.js b/test/unit/pdf_find_controller_spec.js
index 76ac0485fd5bf..c4d18925c821d 100644
--- a/test/unit/pdf_find_controller_spec.js
+++ b/test/unit/pdf_find_controller_spec.js
@@ -128,7 +128,7 @@ function testSearch({
       }
     }
 
-    const totalMatches = matchesPerPage.reduce((a, b) => a + b);
+    const totalMatches = Math.sumPrecise(matchesPerPage);
 
     if (updateFindControlState) {
       eventBus.on(


Reviews:
user: login: timvandermeij
body: r=me, with two questions. Thanks!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T11:29:15Z
user: login: Snuffleupagus
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-23T12:27:04Z
user: login: Snuffleupagus
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-23T12:30:10Z

Review comments:
diff_hunk: @@ -1130,6 +1130,19 @@ if (
   };
 }
 
+// TODO: Remove this once the `javascript.options.experimental.math_sumprecise`
path: src/shared/util.js
user: login: timvandermeij
body: Given the browser compatibility table at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sumPrecise#browser_compatibility should we extend this to say that it can only be removed once the feature landed in the browser versions we support?
created_at: 2025-03-23T11:08:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1130,6 +1130,19 @@ if (
   };
 }
 
+// TODO: Remove this once the `javascript.options.experimental.math_sumprecise`
+//       preference is removed from Firefox.
+if (
+  (typeof PDFJSDev === "undefined" || PDFJSDev.test("SKIP_BABEL || TESTING")) &&
path: src/shared/util.js
user: login: timvandermeij
body: Would it perhaps be safer to unconditionally do the `typeof` check here, given the quite limited browser support? It's for example not entirely clear to me at least if this condition will also work for Chrome outside of testing environments, for example in the addon, because `PDFJSDev` is defined then but not `TESTING` nor `SKIP_BABEL` for modern builds, and if I read this code correctly we won't polyfill it then while the compatibility table lists the feature as unsupported in Chrome.
created_at: 2025-03-23T11:17:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1130,6 +1130,19 @@ if (
   };
 }
 
+// TODO: Remove this once the `javascript.options.experimental.math_sumprecise`
path: src/shared/util.js
user: login: Snuffleupagus
body: These sort of feature rollout prefs are usually not removed until [the proposal](https://github.com/tc39/proposal-math-sum/) reaches stage 4, at which point it should be implemented in all browsers.
In older browsers we should, at that point, be able to rely on a core-js polyfill.
created_at: 2025-03-23T12:27:04Z
author_association: COLLABORATOR
diff_hunk: @@ -1130,6 +1130,19 @@ if (
   };
 }
 
+// TODO: Remove this once the `javascript.options.experimental.math_sumprecise`
+//       preference is removed from Firefox.
+if (
+  (typeof PDFJSDev === "undefined" || PDFJSDev.test("SKIP_BABEL || TESTING")) &&
path: src/shared/util.js
user: login: Snuffleupagus
body: The idea was that we'd only need this crude polyfill in "modern" builds, and that we'd be able to rely on core-js in the `legacy` builds.
While a core-js polyfill do exist, it turns out that the feature is new enough that Babel won't actually include it :-(

For now we can use our polyfill, and use the core-js polyfill later on; thanks for spotting this!
created_at: 2025-03-23T12:30:10Z
author_association: COLLABORATOR

PR comments:
user: login: Snuffleupagus
created_at: 2025-03-23T08:45:48Z
body: /botio test
author_association: COLLABORATOR
user: login: moz-tools-bot
created_at: 2025-03-23T08:45:50Z
body: #### From: Bot.io (Windows)

----

# Received

Command **cmd_test** from @Snuffleupagus received. Current queue size: 0

Live output at: http://54.193.163.58:8877/8d7586d70b89c2f/output.txt
author_association: COLLABORATOR
user: login: moz-tools-bot
created_at: 2025-03-23T08:45:50Z
body: #### From: Bot.io (Linux m4)

----

# Received

Command **cmd_test** from @Snuffleupagus received. Current queue size: 0

Live output at: http://54.241.84.105:8877/a19eea9c31b9dae/output.txt
author_association: COLLABORATOR
user: login: moz-tools-bot
created_at: 2025-03-23T09:16:07Z
body: #### From: Bot.io (Linux m4)

----

# Success

Full output at http://54.241.84.105:8877/a19eea9c31b9dae/output.txt

Total script time: 30.27 mins

+ **Unit tests:** Passed
+ **Integration Tests:** Passed
+ **Regression tests:** Passed
author_association: COLLABORATOR
user: login: moz-tools-bot
created_at: 2025-03-23T09:46:27Z
body: #### From: Bot.io (Windows)

----

# Failed

Full output at http://54.193.163.58:8877/8d7586d70b89c2f/output.txt

Total script time: 60.02 mins


author_association: COLLABORATOR
user: login: moz-tools-bot
created_at: 2025-03-23T12:30:19Z
body: #### From: Bot.io (Linux m4)

----

# Received

Command **cmd_preview** from @Snuffleupagus received. Current queue size: 0

Live output at: http://54.241.84.105:8877/edf9d36a1987158/output.txt
author_association: COLLABORATOR
user: login: moz-tools-bot
created_at: 2025-03-23T12:31:11Z
body: #### From: Bot.io (Linux m4)

----

# Success

Full output at http://54.241.84.105:8877/edf9d36a1987158/output.txt

Total script time: 0.86 mins

#### Published

+ Viewer: http://54.241.84.105:8877/edf9d36a1987158/web/viewer.html
+ Viewer (legacy): http://54.241.84.105:8877/edf9d36a1987158/legacy/web/viewer.html
author_association: COLLABORATOR