Author: tilman
Date: Fri Jul  3 12:18:18 2026
New Revision: 1935835

Log:
PDFBOX-4951: add loader and processor of awt script layout, by Volker Kunert

Added:
   pdfbox/trunk/pdfbox-layout-awt/src/main/
   pdfbox/trunk/pdfbox-layout-awt/src/main/java/
   pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/
   pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/
   pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/
   pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/
   
pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/GlyphLayoutFontLoaderAwt.java
   (contents, props changed)
   
pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/GlyphLayoutProcessorAwt.java
   (contents, props changed)
   pdfbox/trunk/pdfbox-layout-awt/src/test/
   pdfbox/trunk/pdfbox-layout-awt/src/test/java/

Added: 
pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/GlyphLayoutFontLoaderAwt.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/GlyphLayoutFontLoaderAwt.java
 Fri Jul  3 12:18:18 2026        (r1935835)
@@ -0,0 +1,215 @@
+/*
+ * 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.
+ */
+
+package org.apache.pdfbox.layout;
+
+import java.awt.Font;
+import java.awt.FontFormatException;
+import java.awt.font.TextAttribute;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+
+/**
+ * Loads the PDType0Font and awt.Font for GlyphLayoutProcessorAwt
+ * <p>
+ * Use an object of this class only in one thread.
+ *
+ * @author Volker Kunert
+ */
+public class GlyphLayoutFontLoaderAwt
+{
+
+    /**
+     * Mapping from PDFBox font to AWT font
+     */
+    private final Map<PDType0Font, Font> awtFontMap = new 
ConcurrentHashMap<>();
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @param embedSubset True if the font will be subset before embedding. 
Set this to false when
+     * creating a font for AcroForm.
+     * @return pdType0Font PDFBox font
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream 
inputStream, boolean embedSubset)
+            throws IOException, FontFormatException
+    {
+        return loadFont(pdDocument, inputStream, embedSubset, null);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @param embedSubset True if the font will be subset before embedding. 
Set this to false when
+     * creating a font for AcroForm.
+     * @param fontOptions Options for font
+     * @return pdType0Font PDFBox font
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream 
inputStream, boolean embedSubset, FontOptions fontOptions)
+            throws IOException, FontFormatException
+    {
+
+        Objects.requireNonNull(inputStream, "InputStream must not be null");
+        PDType0Font pdType0Font;
+
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream())
+        {
+            // Copy font stream into memory to read it twice
+            // for creation of PDType0Font and aww.Font
+            byte[] buffer = new byte[2048];
+            int bytes_read;
+            while ((bytes_read = inputStream.read(buffer)) > 0)
+            {
+                baos.write(buffer, 0, bytes_read);
+            }
+            try (ByteArrayInputStream bais = new 
ByteArrayInputStream(baos.toByteArray()))
+            {
+                pdType0Font = PDType0Font.load(pdDocument, bais, embedSubset);
+                bais.reset();
+                loadAwtFont(pdType0Font, bais, fontOptions);
+            }
+        }
+        return pdType0Font;
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @return pdType0Font PDFBox font
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream inputStream)
+            throws IOException, FontFormatException
+    {
+        return loadFont(pdDocument, inputStream, true, null);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @param fontOptions options for font
+     * @return pdType0Font PDFBox font
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream 
inputStream, FontOptions fontOptions)
+            throws IOException, FontFormatException
+    {
+        return loadFont(pdDocument, inputStream, true, fontOptions);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdType0Font PDFBox font
+     * @param inputStream of the font file
+     * @param fontOptions Options for font
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    protected void loadAwtFont(PDType0Font pdType0Font, InputStream 
inputStream, FontOptions fontOptions)
+            throws FontFormatException, java.io.IOException
+    {
+        Font awtFont;
+        if (fontOptions == null)
+        {
+            fontOptions = new FontOptions();
+        }
+        if (!awtFontMap.containsKey(pdType0Font))
+        {
+            awtFont = Font.createFont(Font.TRUETYPE_FONT, inputStream)
+                    .deriveFont(fontOptions.getTextAttributes());
+            Objects.requireNonNull(awtFont);
+            awtFontMap.put(pdType0Font, awtFont);
+        }
+    }
+
+    /**
+     * Determines if glyph layout is supported for this font
+     *
+     * @param font PDFBox font
+     * @return true if glyph layout is supported for this font and this font 
is a PDType0Font
+     */
+    public boolean supportsFont(PDFont font)
+    {
+        return font instanceof PDType0Font
+                && awtFontMap.containsKey((PDType0Font) font);
+    }
+
+    /**
+     * Gets the corresponding AWT-font for the given PDFBox-font
+     *
+     * @param font PDFBox font
+     * @return AWT font if available
+     */
+    public Font getAwtFont(PDType0Font font)
+    {
+        return awtFontMap.get(font);
+    }
+
+    /**
+     * Specify Options for an AWT font
+     */
+    public static class FontOptions
+    {
+
+        private final Map<TextAttribute, Object> textAttributes = new 
HashMap<>();
+
+        protected Map<TextAttribute, Object> getTextAttributes()
+        {
+            // always return an unmodifiableMap, so that internal state can 
not be changed
+            // by changing the returned map
+            return Collections.unmodifiableMap(textAttributes);
+        }
+
+        public FontOptions setKerningOn()
+        {
+            textAttributes.put(TextAttribute.KERNING, 
TextAttribute.KERNING_ON);
+            return this;
+        }
+
+        public FontOptions setLigaturesOn()
+        {
+            textAttributes.put(TextAttribute.LIGATURES, 
TextAttribute.LIGATURES_ON);
+            return this;
+        }
+    }
+}

Added: 
pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/GlyphLayoutProcessorAwt.java
==============================================================================
--- /dev/null   00:00:00 1970   (empty, because file is newly added)
+++ 
pdfbox/trunk/pdfbox-layout-awt/src/main/java/org/apache/pdfbox/layout/GlyphLayoutProcessorAwt.java
  Fri Jul  3 12:18:18 2026        (r1935835)
@@ -0,0 +1,351 @@
+/*
+ * 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.
+ */
+package org.apache.pdfbox.layout;
+
+import static java.awt.font.GlyphVector.FLAG_HAS_POSITION_ADJUSTMENTS;
+
+import java.awt.Font;
+import java.awt.FontFormatException;
+import java.awt.font.FontRenderContext;
+import java.awt.font.GlyphVector;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Point2D;
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.Bidi;
+import java.util.Objects;
+
+import org.apache.pdfbox.pdmodel.ContentStreamForGlyphLayoutInterface;
+import org.apache.pdfbox.pdmodel.GlyphLayoutProcessorInterface;
+import org.apache.pdfbox.pdmodel.GlyphsAndPositions;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+
+
+/**
+ * Processor for glyph layout
+ * <p>
+ * Use an object of this class only in one thread.
+ *
+ * @author Volker Kunert
+ */
+public class GlyphLayoutProcessorAwt implements GlyphLayoutProcessorInterface
+{
+
+    private final GlyphLayoutFontLoaderAwt glyphLayoutFontLoaderAwt;
+
+    /**
+     * Constructs a GlyphLayoutProcessorAwt
+     *
+     */
+    public GlyphLayoutProcessorAwt()
+    {
+        this.glyphLayoutFontLoaderAwt = new GlyphLayoutFontLoaderAwt();
+    }
+
+    /**
+     * Checks if the glyphVector contains adjustments that make advanced 
layout necessary
+     *
+     * @param glyphVector glyph vector containing the positions
+     * @return true if the glyphVector contains adjustments
+     */
+    protected static boolean hasAdjustments(GlyphVector glyphVector)
+    {
+        return (glyphVector.getLayoutFlags() & FLAG_HAS_POSITION_ADJUSTMENTS) 
!= 0;
+    }
+
+    /**
+     * Checks if glyphs needed for text are missing in awtFont
+     *
+     * @param text text to be checked
+     * @param awtFont font to be checked
+     * @throws IllegalArgumentException if glyphs are missing
+     */
+    public static void checkMissingGlyphs(String text, Font awtFont)
+    {
+        int firstMissingCharacter = awtFont.canDisplayUpTo(text);
+        if (firstMissingCharacter != -1)
+        {
+            char c = text.charAt(firstMissingCharacter);
+            int codepoint = text.codePointAt(firstMissingCharacter);
+
+            throw new IllegalArgumentException(
+                    String.format("Missing glyph in font '%s' for the 
character '%c', codePoint: %d (U+%04x).",
+                            awtFont.getName(), c, codepoint, codepoint));
+        }
+    }
+
+    /**
+     * Checks if the font is supported
+     * <p>
+     * This class supports OpenType fonts with description of glyphs as 
TrueType outlines, i.e.
+     * *.ttf-files. *.otf-files using CFF outlines are not supported by PDFBox
+     *
+     * @param font to be checked
+     * @return true if glyph layout is supported for this font and this font 
is a PDType0Font
+     */
+    @Override
+    public boolean supportsFont(PDFont font)
+    {
+        return glyphLayoutFontLoaderAwt.supportsFont(font);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @param embedSubset must be false for PDF forms
+     * @param fontOptions options for font
+     *
+     * @return a PDType0Font font.
+     * 
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream 
inputStream, boolean embedSubset,
+            GlyphLayoutFontLoaderAwt.FontOptions fontOptions) throws 
IOException, FontFormatException
+    {
+        return glyphLayoutFontLoaderAwt.loadFont(pdDocument, inputStream, 
embedSubset, fontOptions);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @param embedSubset must be false for PDF forms
+     *
+     * @return a PDType0Font font.
+     *
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream 
inputStream, boolean embedSubset) throws IOException, FontFormatException
+    {
+        return glyphLayoutFontLoaderAwt.loadFont(pdDocument, inputStream, 
embedSubset);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     * @param fontOptions
+     *
+     * @return a PDType0Font font.
+     *
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream inputStream,
+            GlyphLayoutFontLoaderAwt.FontOptions fontOptions) throws 
IOException, FontFormatException
+    {
+        return glyphLayoutFontLoaderAwt.loadFont(pdDocument, inputStream, 
true, fontOptions);
+    }
+
+    /**
+     * Loads the AWT font needed for layout
+     *
+     * @param pdDocument document
+     * @param inputStream of the font
+     *
+     * @return a PDType0Font font.
+     *
+     * @throws IOException if font can not be loaded
+     * @throws FontFormatException if the font is bad
+     */
+    public PDType0Font loadFont(PDDocument pdDocument, InputStream 
inputStream) throws IOException, FontFormatException
+    {
+        return glyphLayoutFontLoaderAwt.loadFont(pdDocument, inputStream, 
true);
+    }
+
+    /**
+     * Computes glyph positioning
+     *
+     * @param font to be used
+     * @param fontSize font size
+     * @param text text to show
+     * @param bidiLevel as computed by Bidi class, even LTR, odd RTL
+     *
+     * @return an awt GlyphVector
+     */
+    protected GlyphVector computeGlyphVector(PDType0Font font, float fontSize, 
String text, int bidiLevel)
+    {
+        Objects.requireNonNull(font, "Font must be set");
+        Objects.requireNonNull(text, "Text must be set");
+
+        char[] chars = text.toCharArray();
+
+        FontRenderContext fontRenderContext = new FontRenderContext(new 
AffineTransform(), false, true);
+        // use fractional metrics
+
+        int localFlags = bidiLevel % 2 == 0 ? Font.LAYOUT_LEFT_TO_RIGHT : 
Font.LAYOUT_RIGHT_TO_LEFT;
+
+        Font awtFont = 
glyphLayoutFontLoaderAwt.getAwtFont(font).deriveFont(fontSize);
+
+        checkMissingGlyphs(text, awtFont);
+
+        return awtFont.layoutGlyphVector(fontRenderContext, chars, 0, 
chars.length, localFlags);
+    }
+
+    /**
+     * Shows a text using glyph positioning (if needed)
+     *
+     * @param contentStream the content stream
+     * @param font to be used
+     * @param fontSize font size
+     * @param text text to show
+     *
+     * @throws IOException if an I/O exception occurs
+     * @throws IllegalArgumentException if glyphs are missing
+     */
+    @Override
+    public void showText(ContentStreamForGlyphLayoutInterface contentStream, 
PDType0Font font, float fontSize, String text) throws IOException
+    {
+        Objects.requireNonNull(text, "Text must be set");
+
+        if (Bidi.requiresBidi(text.toCharArray(), 0, text.length()))
+        {
+            Bidi bidi = new Bidi(text, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT);
+            if (bidi.isMixed())
+            {
+                // Split and Reorder
+                // See PDFTextStripper.handleDirection
+                // collect individual bidi information
+                int runCount = bidi.getRunCount();
+                byte[] levels = new byte[runCount];
+                Integer[] runs = new Integer[runCount];
+
+                for (int i = 0; i < runCount; i++)
+                {
+                    levels[i] = (byte) bidi.getRunLevel(i);
+                    runs[i] = i;
+                }
+                // reorder individual parts based on their levels
+                Bidi.reorderVisually(levels, 0, runs, 0, runCount);
+
+                for (int i = 0; i < runCount; i++)
+                {
+                    int index = runs[i];
+                    int start = bidi.getRunStart(index);
+                    int limit = bidi.getRunLimit(index);
+                    int bidiLevel = levels[index];
+                    String part = text.substring(start, limit);
+                    showTextUni(contentStream, font, fontSize, part, 
bidiLevel);
+                }
+            }
+            else
+            {
+                showTextUni(contentStream, font, fontSize, text, 
bidi.getBaseLevel());
+            }
+        }
+        else
+        {
+            showTextUni(contentStream, font, fontSize, text, 
Bidi.DIRECTION_LEFT_TO_RIGHT);
+        }
+    }
+
+    /**
+     * Shows a text using glyph positioning (if needed) This text must have a 
uniform run direction.
+     *
+     * @param contentStream the content stream
+     * @param font to be used
+     * @param fontSize font size
+     * @param text text to show
+     * @param bidiLevel as computed by Bidi class, even LTR, odd RTL
+     * @throws IOException if an IO-exception occurs
+     * @throws IllegalArgumentException if glyphs are missing
+     */
+    protected void showTextUni(ContentStreamForGlyphLayoutInterface 
contentStream, PDType0Font font, float fontSize, String text, int bidiLevel) 
throws IOException
+    {
+        Objects.requireNonNull(text, "Text must be set");
+        Objects.requireNonNull(contentStream, "contentStream must be set");
+
+        GlyphVector glyphVector = computeGlyphVector(font, fontSize, text, 
bidiLevel);
+
+        if (!hasAdjustments(glyphVector))
+        {
+            showGlyphVector(contentStream, glyphVector);
+            return;
+        }
+
+        final float delta = 1e-5f;
+        final float factorX = 1000f / fontSize;
+        float lastX = 0f;
+
+        GlyphsAndPositions ga = new GlyphsAndPositions();
+
+        for (int i = 0; i < glyphVector.getNumGlyphs(); i++)
+        {
+            Point2D p = glyphVector.getGlyphPosition(i);
+            float ax = (i == 0) ? 0.0f : glyphVector.getGlyphMetrics(i - 
1).getAdvanceX();
+            float dx = (float) p.getX() - lastX - ax;
+            float py = (float) p.getY();
+
+            if (Math.abs(py) >= delta)
+            {
+                if (!ga.isEmpty())
+                {
+                    contentStream.showGlyphsWithPositioning(ga);
+                    ga.clear();
+                }
+                contentStream.setTextRise(-py);
+            }
+            if (Math.abs(dx) >= delta)
+            {
+                ga.add(-dx * factorX);
+            }
+            ga.add(glyphVector.getGlyphCode(i));
+            if (Math.abs(py) >= delta)
+            {
+                contentStream.showGlyphsWithPositioning(ga);
+                ga.clear();
+                contentStream.setTextRise(0.0f);
+            }
+            lastX = (float) p.getX();
+        }
+        // adjust the end position
+        Point2D p = glyphVector.getGlyphPosition(glyphVector.getNumGlyphs());
+        float ax = (glyphVector.getNumGlyphs() == 0) ? 0.0f
+                : glyphVector.getGlyphMetrics(glyphVector.getNumGlyphs() - 
1).getAdvanceX();
+        float dx = (float) p.getX() - lastX - ax;
+        if (Math.abs(dx) >= delta)
+        {
+            ga.add(-dx * factorX);
+        }
+        contentStream.showGlyphsWithPositioning(ga);
+        ga.clear();
+    }
+
+    /**
+     * Shows the glyphs for the given glyphVector
+     *
+     * @param contentStream the content stream
+     * @param glyphVector the glyphVector to be shown
+     * @throws IOException if an I/O exception occurs
+     */
+    protected void showGlyphVector(ContentStreamForGlyphLayoutInterface 
contentStream, GlyphVector glyphVector) throws IOException
+    {
+        Objects.requireNonNull(glyphVector, "glyphVector must be set");
+        Objects.requireNonNull(contentStream, "contentStream must be set");
+
+        int[] glyphCodes = glyphVector.getGlyphCodes(0, 
glyphVector.getNumGlyphs(), new int[glyphVector.getNumGlyphs()]);
+        contentStream.showGlyphCodes(glyphCodes);
+    }
+}

Reply via email to