From 477787aaf7c1185f2d7f89c33481e4970a9326a5 Mon Sep 17 00:00:00 2001 From: Jason Harrop Date: Fri, 31 Jul 2026 07:41:51 +1000 Subject: [PATCH] FOP-3330 Pack MultiByteFont's glyph bounding boxes as ints MultiByteFont holds a java.awt.Rectangle per glyph, populated eagerly by OFFontLoader for every CID font that is loaded. A Rectangle costs 32 bytes plus 4 for the array slot, to carry four ints that need 16, and it is retained for as long as the font is: some 180KB for a 5,000 glyph font, 2.3MB for a 65,000 glyph CJK one. Measured over the 1246 fonts installed on one machine (6.9M glyphs), by loading the metrics of each and taking a live object histogram: 250MB of Rectangles and their arrays, which packing takes to 111MB, and 6.1M live objects to 594k. So store them packed as x, y, width, height per glyph. getBoundingBox(glyphIndex, size) already allocated a new, scaled Rectangle for its caller, and is the only reader of the field, so no caller sees a difference. setBBoxArray(Rectangle[]) is kept, converting, since it is public API; OpenFont.getBoundingBoxesPacked() lets OFFontLoader skip building the Rectangle[] at load time as well. getBoundingBoxes() is unchanged, still used for the single byte path. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/apache/fop/fonts/MultiByteFont.java | 30 +++++++-- .../fop/fonts/truetype/OFFontLoader.java | 2 +- .../apache/fop/fonts/truetype/OpenFont.java | 17 +++++ .../fop/fonts/MultiByteFontTestCase.java | 63 +++++++++++++++++++ .../fop/fonts/truetype/TTFFileTestCase.java | 19 ++++++ 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 fop-core/src/test/java/org/apache/fop/fonts/MultiByteFontTestCase.java diff --git a/fop-core/src/main/java/org/apache/fop/fonts/MultiByteFont.java b/fop-core/src/main/java/org/apache/fop/fonts/MultiByteFont.java index cbaace8a2e9..766a09f92ed 100644 --- a/fop-core/src/main/java/org/apache/fop/fonts/MultiByteFont.java +++ b/fop-core/src/main/java/org/apache/fop/fonts/MultiByteFont.java @@ -77,8 +77,11 @@ public class MultiByteFont extends CIDFont implements Substitutable, Positionabl private int firstUnmapped; private int lastUnmapped; - /** Contains the character bounding boxes for all characters in the font */ - protected Rectangle[] boundingBoxes; + /** Contains the character bounding boxes for all characters in the font, + * packed as x, y, width, height per glyph. A Rectangle per glyph costs 36 + * bytes (32 for the object, 4 for the array slot) where these cost 16, and + * the array is retained for as long as the font is loaded. */ + protected int[] boundingBoxes; private boolean isOTFFile; @@ -210,8 +213,9 @@ public int[] getWidths() { public Rectangle getBoundingBox(int glyphIndex, int size) { int index = isEmbeddable() ? cidSet.getOriginalGlyphIndex(glyphIndex) : glyphIndex; - Rectangle bbox = boundingBoxes[index]; - return new Rectangle(bbox.x * size, bbox.y * size, bbox.width * size, bbox.height * size); + int i = index * 4; + return new Rectangle(boundingBoxes[i] * size, boundingBoxes[i + 1] * size, + boundingBoxes[i + 2] * size, boundingBoxes[i + 3] * size); } /** @@ -451,6 +455,24 @@ public void setWidthArray(int[] wds) { * @param boundingBoxes array of bounding boxes. */ public void setBBoxArray(Rectangle[] boundingBoxes) { + int[] packed = new int[boundingBoxes.length * 4]; + for (int i = 0; i < boundingBoxes.length; i++) { + Rectangle bbox = boundingBoxes[i]; + packed[i * 4] = bbox.x; + packed[i * 4 + 1] = bbox.y; + packed[i * 4 + 2] = bbox.width; + packed[i * 4 + 3] = bbox.height; + } + this.boundingBoxes = packed; + } + + /** + * Sets the bounding boxes array, packed as x, y, width, height per glyph. + * Preferred over setBBoxArray(Rectangle[]), which allocates a Rectangle per + * glyph merely to pass the values in. + * @param boundingBoxes packed bounding boxes, 4 ints per glyph. + */ + public void setBBoxArray(int[] boundingBoxes) { this.boundingBoxes = boundingBoxes; } diff --git a/fop-core/src/main/java/org/apache/fop/fonts/truetype/OFFontLoader.java b/fop-core/src/main/java/org/apache/fop/fonts/truetype/OFFontLoader.java index 895b798f713..c66ba29d2c5 100644 --- a/fop-core/src/main/java/org/apache/fop/fonts/truetype/OFFontLoader.java +++ b/fop-core/src/main/java/org/apache/fop/fonts/truetype/OFFontLoader.java @@ -193,7 +193,7 @@ private void buildFont(OpenFont otf, String ttcFontName) { multiFont.setCIDType(CIDFontType.CIDTYPE2); } multiFont.setWidthArray(otf.getWidths()); - multiFont.setBBoxArray(otf.getBoundingBoxes()); + multiFont.setBBoxArray(otf.getBoundingBoxesPacked()); } else { singleFont.setFontType(FontType.TRUETYPE); singleFont.setEncoding(otf.getCharSetName()); diff --git a/fop-core/src/main/java/org/apache/fop/fonts/truetype/OpenFont.java b/fop-core/src/main/java/org/apache/fop/fonts/truetype/OpenFont.java index 95113f40763..84bde41ffda 100644 --- a/fop-core/src/main/java/org/apache/fop/fonts/truetype/OpenFont.java +++ b/fop-core/src/main/java/org/apache/fop/fonts/truetype/OpenFont.java @@ -1128,6 +1128,23 @@ public int[] getWidths() { return wx; } + /** + * The bounding boxes of all glyphs, packed as x, y, width, height per glyph + * (so 4 ints each). The same values as getBoundingBoxes(), without a + * Rectangle per glyph. + */ + public int[] getBoundingBoxesPacked() { + int[] packed = new int[mtxTab.length * 4]; + for (int i = 0; i < mtxTab.length; i++) { + int[] boundingBox = mtxTab[i].getBoundingBox(); + packed[i * 4] = convertTTFUnit2PDFUnit(boundingBox[0]); + packed[i * 4 + 1] = convertTTFUnit2PDFUnit(boundingBox[1]); + packed[i * 4 + 2] = convertTTFUnit2PDFUnit(boundingBox[2] - boundingBox[0]); + packed[i * 4 + 3] = convertTTFUnit2PDFUnit(boundingBox[3] - boundingBox[1]); + } + return packed; + } + public Rectangle[] getBoundingBoxes() { Rectangle[] boundingBoxes = new Rectangle[mtxTab.length]; for (int i = 0; i < boundingBoxes.length; i++) { diff --git a/fop-core/src/test/java/org/apache/fop/fonts/MultiByteFontTestCase.java b/fop-core/src/test/java/org/apache/fop/fonts/MultiByteFontTestCase.java new file mode 100644 index 00000000000..01c00f22be5 --- /dev/null +++ b/fop-core/src/test/java/org/apache/fop/fonts/MultiByteFontTestCase.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +/* $Id$ */ + +package org.apache.fop.fonts; + +import java.awt.Rectangle; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +/** + * Tests for MultiByteFont's glyph bounding boxes, which are stored packed as + * ints rather than as a Rectangle per glyph. + */ +public class MultiByteFontTestCase { + + /** Not embeddable (there is no embed URI), so bounding boxes are indexed by glyph index. */ + private MultiByteFont font() { + return new MultiByteFont(null, EmbeddingMode.AUTO); + } + + @Test + public void testPackedBBoxArray() { + MultiByteFont font = font(); + font.setBBoxArray(new int[] {1, 2, 3, 4, 5, 6, 7, 8}); + + assertEquals(new Rectangle(1, 2, 3, 4), font.getBoundingBox(0, 1)); + assertEquals(new Rectangle(5, 6, 7, 8), font.getBoundingBox(1, 1)); + } + + @Test + public void testRectangleBBoxArray() { + MultiByteFont font = font(); + font.setBBoxArray(new Rectangle[] {new Rectangle(1, 2, 3, 4), new Rectangle(5, 6, 7, 8)}); + + assertEquals(new Rectangle(1, 2, 3, 4), font.getBoundingBox(0, 1)); + assertEquals(new Rectangle(5, 6, 7, 8), font.getBoundingBox(1, 1)); + } + + @Test + public void testBoundingBoxIsScaledBySize() { + MultiByteFont font = font(); + font.setBBoxArray(new int[] {-1, 2, 3, 4}); + + assertEquals(new Rectangle(-10, 20, 30, 40), font.getBoundingBox(0, 10)); + } +} diff --git a/fop-core/src/test/java/org/apache/fop/fonts/truetype/TTFFileTestCase.java b/fop-core/src/test/java/org/apache/fop/fonts/truetype/TTFFileTestCase.java index 24b3b396dc5..5c48f8adbb4 100644 --- a/fop-core/src/test/java/org/apache/fop/fonts/truetype/TTFFileTestCase.java +++ b/fop-core/src/test/java/org/apache/fop/fonts/truetype/TTFFileTestCase.java @@ -19,6 +19,7 @@ package org.apache.fop.fonts.truetype; +import java.awt.Rectangle; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; @@ -577,6 +578,24 @@ public void testBBox() { assertEquals(dejavuTTFFile.getBBox(2330).length, 4); } + /** + * Test getBoundingBoxesPacked() - it must carry the same values as + * getBoundingBoxes(), which it exists to avoid allocating. + */ + @Test + public void testGetBoundingBoxesPacked() { + Rectangle[] boundingBoxes = dejavuTTFFile.getBoundingBoxes(); + int[] packed = dejavuTTFFile.getBoundingBoxesPacked(); + assertEquals(boundingBoxes.length * 4, packed.length); + for (int i = 0; i < boundingBoxes.length; i++) { + Rectangle boundingBox = boundingBoxes[i]; + assertEquals(boundingBox.x, packed[i * 4]); + assertEquals(boundingBox.y, packed[i * 4 + 1]); + assertEquals(boundingBox.width, packed[i * 4 + 2]); + assertEquals(boundingBox.height, packed[i * 4 + 3]); + } + } + @Test public void testReservedIndex() throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream();