You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@xmlgraphics.apache.org by ss...@apache.org on 2026/09/25 09:28:43 UTC

(xmlgraphics-fop) branch main updated: FOP-3337: Add AFP truetype complex script support

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/xmlgraphics-fop.git


The following commit(s) were added to refs/heads/main by this push:
     new feb2323ca FOP-3337: Add AFP truetype complex script support
feb2323ca is described below

commit feb2323ca7ab8ef67f696f3204721624c480b731
Author: Simon Steiner <ss...@apache.org>
AuthorDate: Fri Sep 25 10:28:28 2026 +0100

    FOP-3337: Add AFP truetype complex script support
---
 .../org/apache/fop/afp/AFPResourceManager.java     |  68 ++-----
 .../java/org/apache/fop/afp/AFPTrailerFont.java    | 149 ++++++++++++++
 .../java/org/apache/fop/fonts/MultiByteFont.java   |   4 +
 .../org/apache/fop/fonts/truetype/CmapWriter.java  | 223 +++++++++++++++++++++
 .../apache/fop/render/afp/AFPDocumentHandler.java  |   2 +-
 .../org/apache/fop/render/afp/AFPFontConfig.java   |  41 +++-
 .../org/apache/fop/afp/AFPTrailerFontTestCase.java | 104 ++++++++++
 .../fop/fonts/truetype/CmapWriterTestCase.java     |  81 ++++++++
 .../apache/fop/render/afp/AFPTrueTypeTestCase.java |  53 ++++-
 9 files changed, 667 insertions(+), 58 deletions(-)

diff --git a/fop-core/src/main/java/org/apache/fop/afp/AFPResourceManager.java b/fop-core/src/main/java/org/apache/fop/afp/AFPResourceManager.java
index f48e7223f..cc638dc14 100644
--- a/fop-core/src/main/java/org/apache/fop/afp/AFPResourceManager.java
+++ b/fop-core/src/main/java/org/apache/fop/afp/AFPResourceManager.java
@@ -39,19 +39,16 @@ import org.apache.fop.afp.fonts.AFPFont;
 import org.apache.fop.afp.fonts.CharacterSet;
 import org.apache.fop.afp.modca.AbstractNamedAFPObject;
 import org.apache.fop.afp.modca.AbstractPageObject;
-import org.apache.fop.afp.modca.ActiveEnvironmentGroup;
 import org.apache.fop.afp.modca.IncludeObject;
 import org.apache.fop.afp.modca.IncludedResourceObject;
-import org.apache.fop.afp.modca.ObjectContainer;
 import org.apache.fop.afp.modca.PageSegment;
 import org.apache.fop.afp.modca.Registry;
 import org.apache.fop.afp.modca.ResourceGroup;
 import org.apache.fop.afp.modca.ResourceObject;
-import org.apache.fop.afp.modca.triplets.EncodingTriplet;
-import org.apache.fop.afp.modca.triplets.FullyQualifiedNameTriplet;
 import org.apache.fop.afp.util.AFPResourceAccessor;
 import org.apache.fop.afp.util.AFPResourceUtil;
 import org.apache.fop.apps.io.InternalResourceResolver;
+import org.apache.fop.fonts.CMapSegment;
 import org.apache.fop.fonts.FontType;
 import org.apache.fop.render.afp.AFPFontConfig;
 
@@ -84,6 +81,8 @@ public class AFPResourceManager {
 
     protected boolean includeCached = true;
 
+    private Map<AFPResourceInfo, AFPTrailerFont> trailerFonts = new HashMap<>();
+
     /**
      * Main constructor
      *
@@ -125,6 +124,9 @@ public class AFPResourceManager {
      * @throws IOException thrown if an I/O exception of some sort has occurred.
      */
     public void writeToStream() throws IOException {
+        for (AFPTrailerFont font : trailerFonts.values()) {
+            font.build();
+        }
         streamer.close();
     }
 
@@ -348,11 +350,12 @@ public class AFPResourceManager {
             if (charSet.getResourceAccessor() != null) {
                 AFPResourceAccessor accessor = charSet.getResourceAccessor();
                 if (afpFont.getFontType() == FontType.TRUETYPE) {
-
+                    AFPFontConfig.AFPTrueTypeFont ttf = (AFPFontConfig.AFPTrueTypeFont) afpFont;
+                    CMapSegment[] cmap = ttf.hasPrivateUseSubstitutions() ? ttf.getCMap() : null;
                     createIncludedResource(afpFont.getFontName(),
-                            ((AFPFontConfig.AFPTrueTypeFont) afpFont).getUri(), accessor,
+                            ttf.getUri(), accessor,
                             ResourceObject.TYPE_OBJECT_CONTAINER, true,
-                            ((AFPFontConfig.AFPTrueTypeFont) afpFont).getTTC());
+                            ttf.getTTC(), cmap);
                 } else {
                     createIncludedResource(
                             charSet.getName(), accessor,
@@ -391,7 +394,7 @@ public class AFPResourceManager {
                     + " (" + e.getMessage() + ")");
         }
 
-        createIncludedResource(resourceName, uri, accessor, resourceObjectType, false, null);
+        createIncludedResource(resourceName, uri, accessor, resourceObjectType, false, null, null);
     }
 
     /**
@@ -403,7 +406,8 @@ public class AFPResourceManager {
      * @throws IOException if an I/O error occurs while loading the resource
      */
     public void createIncludedResource(String resourceName, URI uri, AFPResourceAccessor accessor,
-                                       byte resourceObjectType, boolean truetype, String ttc) throws IOException {
+                                       byte resourceObjectType, boolean truetype, String ttc,
+                                       CMapSegment[] cmap) throws IOException {
         AFPResourceLevel resourceLevel = new AFPResourceLevel(ResourceType.PRINT_FILE);
 
         AFPResourceInfo resourceInfo = new AFPResourceInfo();
@@ -420,29 +424,8 @@ public class AFPResourceManager {
             ResourceGroup resourceGroup = streamer.getResourceGroup(resourceLevel);
 
             if (truetype) {
-                ResourceObject res = factory.createResource();
-                res.setType(ResourceObject.TYPE_OBJECT_CONTAINER);
-
-                ActiveEnvironmentGroup.setupTruetypeMDR(res, false);
-
-                ObjectContainer oc = factory.createObjectContainer();
-                InputStream is = accessor.createInputStream(uri);
-
-                if (ttc != null) {
-                    oc.setData(extractTTC(ttc, is));
-                } else {
-                    oc.setData(IOUtils.toByteArray(is));
-                }
-
-                ActiveEnvironmentGroup.setupTruetypeMDR(oc, true);
-
-                res.addTriplet(new EncodingTriplet(1200));
-
-                res.setFullyQualifiedName(FullyQualifiedNameTriplet.TYPE_REPLACE_FIRST_GID_NAME,
-                        FullyQualifiedNameTriplet.FORMAT_CHARSTR, resourceName, true);
-
-                res.setDataObject(oc);
-                resourceGroup.addObject(res);
+                trailerFonts.put(resourceInfo,
+                        new AFPTrailerFont(accessor, uri, ttc, cmap, resourceName, resourceGroup, factory));
             } else {
                 ResourceObject resourceObject = factory.createResource(resourceName);
                 IncludedResourceObject resourceContent = new IncludedResourceObject(
@@ -457,28 +440,11 @@ public class AFPResourceManager {
 
             // record mapping of resource info to data object resource name
             addToCache(resourceInfo, newcachedObject);
-        } else {
-            //skip, already created
+        } else if (truetype) {
+            trailerFonts.get(resourceInfo).cmap = cmap;
         }
     }
 
-    private byte[] extractTTC(String ttc, InputStream is) throws IOException {
-//        TrueTypeCollection trueTypeCollection = new TrueTypeCollection(is);
-//        for (TrueTypeFont ttf : trueTypeCollection.getFonts()) {
-//            String name = ttf.getNaming().getFontFamily();
-//            if (name.equals(ttc)) {
-//                ByteArrayOutputStream bos = new ByteArrayOutputStream();
-//                TTFSubsetter s = new TTFSubsetter(ttf, null);
-//                for (int i = 0; i < 256 * 256; i++) {
-//                    s.addCharCode(i);
-//                }
-//                s.writeToStream(bos);
-//                return bos.toByteArray();
-//            }
-//        }
-        throw new IOException(ttc + " not supported");
-    }
-
     /**
      * Creates an included resource extracting the named resource from an external source.
      * @param resourceName the name of the resource
diff --git a/fop-core/src/main/java/org/apache/fop/afp/AFPTrailerFont.java b/fop-core/src/main/java/org/apache/fop/afp/AFPTrailerFont.java
new file mode 100644
index 000000000..751da5f38
--- /dev/null
+++ b/fop-core/src/main/java/org/apache/fop/afp/AFPTrailerFont.java
@@ -0,0 +1,149 @@
+/*
+ * 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.afp;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.fontbox.ttf.TTFTable;
+import org.apache.fontbox.ttf.TrueTypeCollection;
+import org.apache.fontbox.ttf.TrueTypeFont;
+
+import org.apache.fop.afp.modca.ActiveEnvironmentGroup;
+import org.apache.fop.afp.modca.ObjectContainer;
+import org.apache.fop.afp.modca.ResourceGroup;
+import org.apache.fop.afp.modca.ResourceObject;
+import org.apache.fop.afp.modca.triplets.EncodingTriplet;
+import org.apache.fop.afp.modca.triplets.FullyQualifiedNameTriplet;
+import org.apache.fop.afp.util.AFPResourceAccessor;
+import org.apache.fop.fonts.CMapSegment;
+import org.apache.fop.fonts.truetype.CmapWriter;
+
+/**
+ * Builds an AFP object container resource holding an embedded TrueType font, written to the
+ * document trailer's resource group. The full font is read from the given URI (extracted from a
+ * TrueType Collection when a {@code ttc} name is supplied), optionally has a cmap appended, and is
+ * wrapped in the MO:DCA structures required to reference it as a TrueType font resource.
+ */
+public class AFPTrailerFont {
+    private AFPResourceAccessor accessor;
+    private URI uri;
+    private String ttc;
+    private String resourceName;
+    private ResourceGroup resourceGroup;
+    private Factory factory;
+    protected CMapSegment[] cmap;
+
+    AFPTrailerFont(AFPResourceAccessor accessor, URI uri, String ttc, CMapSegment[] cmap, String resourceName,
+                ResourceGroup resourceGroup, Factory factory) {
+        this.accessor = accessor;
+        this.uri = uri;
+        this.ttc = ttc;
+        this.cmap = cmap;
+        this.resourceName = resourceName;
+        this.resourceGroup = resourceGroup;
+        this.factory = factory;
+    }
+
+    public void build() throws IOException {
+        ResourceObject res = factory.createResource();
+        res.setType(ResourceObject.TYPE_OBJECT_CONTAINER);
+
+        ActiveEnvironmentGroup.setupTruetypeMDR(res, false);
+
+        ObjectContainer objectContainer = factory.createObjectContainer();
+        InputStream is = accessor.createInputStream(uri);
+
+        byte[] fontData;
+        if (ttc != null) {
+            fontData = extractTTC(ttc, is);
+        } else {
+            fontData = IOUtils.toByteArray(is);
+        }
+        if (cmap != null) {
+            fontData = CmapWriter.appendCmap(fontData, cmap);
+        }
+        objectContainer.setData(fontData);
+
+        ActiveEnvironmentGroup.setupTruetypeMDR(objectContainer, true);
+
+        res.addTriplet(new EncodingTriplet(1200));
+
+        res.setFullyQualifiedName(FullyQualifiedNameTriplet.TYPE_REPLACE_FIRST_GID_NAME,
+                FullyQualifiedNameTriplet.FORMAT_CHARSTR, resourceName, true);
+
+        res.setDataObject(objectContainer);
+        resourceGroup.addObject(res);
+    }
+
+    private byte[] extractTTC(String ttc, InputStream is) throws IOException {
+        TrueTypeCollection trueTypeCollection = new TrueTypeCollection(is);
+        TrueTypeFont ttf = trueTypeCollection.getFontByName(ttc);
+        return extractFullFont(ttf);
+    }
+
+    protected static byte[] extractFullFont(TrueTypeFont ttf) throws IOException {
+        byte[] source = IOUtils.toByteArray(ttf.getOriginalData());
+        List<TTFTable> tables = new ArrayList<>(ttf.getTables());
+        Collections.sort(tables, new Comparator<TTFTable>() {
+            public int compare(TTFTable table1, TTFTable table2) {
+                return table1.getTag().compareTo(table2.getTag());
+            }
+        });
+        int numTables = tables.size();
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        DataOutputStream out = new DataOutputStream(bos);
+        out.writeInt(0x00010000); // sfnt version (TrueType outlines)
+        out.writeShort(numTables);
+        int entrySelector = 0;
+        while ((1 << (entrySelector + 1)) <= numTables) {
+            entrySelector++;
+        }
+        int searchRange = (1 << entrySelector) * 16;
+        out.writeShort(searchRange);
+        out.writeShort(entrySelector);
+        out.writeShort(numTables * 16 - searchRange);
+        int offset = 12 + numTables * 16;
+        for (TTFTable table : tables) {
+            out.writeBytes(table.getTag());
+            out.writeInt((int) table.getCheckSum());
+            out.writeInt(offset);
+            out.writeInt((int) table.getLength());
+            offset += (int) ((table.getLength() + 3) & ~3L);
+        }
+        for (TTFTable table : tables) {
+            int length = (int) table.getLength();
+            out.write(source, (int) table.getOffset(), length);
+            while (length % 4 != 0) {
+                out.write(0);
+                length++;
+            }
+        }
+        out.flush();
+        return bos.toByteArray();
+    }
+}
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 cbaace8a2..959c4c10d 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
@@ -303,6 +303,10 @@ public class MultiByteFont extends CIDFont implements Substitutable, Positionabl
         }
     }
 
+    public boolean hasPrivateUseSubstitutions() {
+        return numMapped > 0;
+    }
+
     /**
      * Returns the Unicode scalar value that corresponds to the glyph index. If more than
      * one correspondence exists, then the first one is returned (ordered by bfentries[]).
diff --git a/fop-core/src/main/java/org/apache/fop/fonts/truetype/CmapWriter.java b/fop-core/src/main/java/org/apache/fop/fonts/truetype/CmapWriter.java
new file mode 100644
index 000000000..55956ec0f
--- /dev/null
+++ b/fop-core/src/main/java/org/apache/fop/fonts/truetype/CmapWriter.java
@@ -0,0 +1,223 @@
+/*
+ * 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.truetype;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.fop.fonts.CMapSegment;
+
+/**
+ * Rewrites the {@code cmap} table of an sfnt (TrueType) font so that it contains the character to
+ * glyph-index mappings held by FOP for that font, including the Private Use Area code points that
+ * {@code MultiByteFont.createPrivateUseMapping} allocates for complex-script (GSUB) output.
+ *
+ * <p>The AFP renderer writes text as UTF-16 code points and the AFP consumer resolves each code
+ * point to a glyph through the embedded font's {@code cmap}. Substituted glyphs are exposed by FOP
+ * as PUA code points which are, by construction, absent from the original font's {@code cmap};
+ * without this rewrite they would not resolve.</p>
+ *
+ * <p>The rewrite is deliberately minimal: the original font bytes are preserved, a fresh
+ * {@code cmap} table (a single Windows BMP, format&nbsp;4 subtable) is appended on a 4-byte
+ * boundary, and only the {@code cmap} entry in the sfnt table directory is repointed to it. The
+ * original {@code cmap} bytes remain in the file as unreferenced dead space. The font's glyph
+ * numbering is left untouched, so the {@link CMapSegment#getGlyphStartIndex() glyph indices} line up
+ * with the embedded glyphs.</p>
+ */
+public final class CmapWriter {
+
+    private static final int SFNT_HEADER_SIZE = 12;
+    private static final int DIR_ENTRY_SIZE = 16;
+    private static final int BMP_MAX = 0xFFFF;
+    private static final long CHECKSUM_MAGIC = 0xB1B0AFBAL;
+
+    private CmapWriter() {
+    }
+
+    /**
+     * Returns a copy of the given TrueType font whose {@code cmap} table maps the supplied segments.
+     *
+     * @param font the original TrueType font bytes (must keep its original glyph numbering)
+     * @param segments the character-to-glyph mappings to encode (original mappings plus PUA additions)
+     * @return the rewritten font bytes
+     * @throws IOException if the font has no {@code cmap} or {@code head} table
+     */
+    public static byte[] appendCmap(byte[] font, CMapSegment[] segments) throws IOException {
+        int numTables = readUShort(font, 4);
+        int cmapRecordOffset = -1;
+        int headTableOffset = -1;
+        for (int i = 0; i < numTables; i++) {
+            int recordOffset = SFNT_HEADER_SIZE + i * DIR_ENTRY_SIZE;
+            String tag = readTag(font, recordOffset);
+            if ("cmap".equals(tag)) {
+                cmapRecordOffset = recordOffset;
+            } else if ("head".equals(tag)) {
+                headTableOffset = readULong(font, recordOffset + 8);
+            }
+        }
+        if (cmapRecordOffset < 0) {
+            throw new IOException("Font has no cmap table to rewrite");
+        }
+        if (headTableOffset < 0) {
+            throw new IOException("Font has no head table");
+        }
+
+        byte[] cmapTable = buildCmapTable(segments);
+
+        int appendOffset = align4(font.length);
+        int newLength = align4(appendOffset + cmapTable.length);
+        byte[] out = new byte[newLength];
+        System.arraycopy(font, 0, out, 0, font.length);
+        System.arraycopy(cmapTable, 0, out, appendOffset, cmapTable.length);
+
+        // Repoint the cmap directory entry at the appended table.
+        writeULong(out, cmapRecordOffset + 4, TTFSubSetFile.getCheckSum(out, appendOffset, cmapTable.length));
+        writeULong(out, cmapRecordOffset + 8, appendOffset);
+        writeULong(out, cmapRecordOffset + 12, cmapTable.length);
+
+        // Recompute head.checkSumAdjustment over the whole file (with the field zeroed first).
+        writeULong(out, headTableOffset + 8, 0);
+        int checkSum = TTFSubSetFile.getCheckSum(out, 0, newLength);
+        writeULong(out, headTableOffset + 8, (int) (CHECKSUM_MAGIC - (checkSum & 0xFFFFFFFFL)));
+
+        return out;
+    }
+
+    /** Builds a complete cmap table: table header + one (3,1) encoding record + a format-4 subtable. */
+    private static byte[] buildCmapTable(CMapSegment[] segments) throws IOException {
+        byte[] subtable = buildFormat4Subtable(segments);
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        DataOutputStream out = new DataOutputStream(bos);
+        out.writeShort(0);      // table version
+        out.writeShort(1);      // number of encoding records
+        out.writeShort(3);      // platformID: Windows
+        out.writeShort(1);      // encodingID: Unicode BMP
+        out.writeInt(12);       // offset to subtable (4 header + 8 encoding record)
+        out.write(subtable);
+        out.flush();
+        return bos.toByteArray();
+    }
+
+    /** Builds a format-4 (segment mapping to delta values) subtable for BMP code points. */
+    private static byte[] buildFormat4Subtable(CMapSegment[] segments) throws IOException {
+        List<int[]> ranges = buildRanges(segments);
+        // Mandatory terminating segment 0xFFFF -> 0xFFFF.
+        ranges.add(new int[] {BMP_MAX, BMP_MAX, 1});
+
+        int segCount = ranges.size();
+        int segCountX2 = segCount * 2;
+        int entrySelector = 0;
+        while ((1 << (entrySelector + 1)) <= segCount) {
+            entrySelector++;
+        }
+        int searchRange = (1 << entrySelector) * 2;
+        int rangeShift = segCountX2 - searchRange;
+
+        int length = 16 + 8 * segCount;   // header (14) + reservedPad (2) + 4 arrays of segCount USHORTs
+
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        DataOutputStream out = new DataOutputStream(bos);
+        out.writeShort(4);              // format
+        out.writeShort(length);
+        out.writeShort(0);              // language
+        out.writeShort(segCountX2);
+        out.writeShort(searchRange);
+        out.writeShort(entrySelector);
+        out.writeShort(rangeShift);
+        for (int[] range : ranges) {    // endCode[]
+            out.writeShort(range[1]);
+        }
+        out.writeShort(0);              // reservedPad
+        for (int[] range : ranges) {    // startCode[]
+            out.writeShort(range[0]);
+        }
+        for (int[] range : ranges) {    // idDelta[]
+            out.writeShort(range[2]);
+        }
+        for (int i = 0; i < segCount; i++) {    // idRangeOffset[] (all zero)
+            out.writeShort(0);
+        }
+        out.flush();
+        return bos.toByteArray();
+    }
+
+    /**
+     * Converts the FOP cmap segments into sorted, non-overlapping BMP ranges of
+     * {startCode, endCode, idDelta}. Supplementary-plane segments are dropped and ranges are clamped
+     * to the BMP; the terminating 0xFFFF segment is added by the caller.
+     */
+    private static List<int[]> buildRanges(CMapSegment[] segments) {
+        CMapSegment[] sorted = segments.clone();
+        Arrays.sort(sorted, new Comparator<CMapSegment>() {
+            public int compare(CMapSegment a, CMapSegment b) {
+                return Integer.compare(a.getUnicodeStart(), b.getUnicodeStart());
+            }
+        });
+        List<int[]> ranges = new ArrayList<>();
+        int lastEnd = -1;
+        for (CMapSegment segment : sorted) {
+            int start = segment.getUnicodeStart();
+            int end = segment.getUnicodeEnd();
+            if (start >= BMP_MAX || start <= lastEnd) {
+                continue;   // cannot be represented in a format-4 subtable
+            }
+            if (end >= BMP_MAX) {
+                end = BMP_MAX - 1;
+            }
+            int idDelta = (segment.getGlyphStartIndex() - start) & 0xFFFF;
+            ranges.add(new int[] {start, end, idDelta});
+            lastEnd = end;
+        }
+        return ranges;
+    }
+
+    private static int align4(int value) {
+        int pad = 4 - (value % 4);
+        return pad < 4 ? value + pad : value;
+    }
+
+    private static String readTag(byte[] data, int pos) {
+        return new String(new char[] {
+            (char) (data[pos] & 0xff), (char) (data[pos + 1] & 0xff),
+            (char) (data[pos + 2] & 0xff), (char) (data[pos + 3] & 0xff),
+        });
+    }
+
+    private static int readUShort(byte[] data, int pos) {
+        return ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+    }
+
+    private static int readULong(byte[] data, int pos) {
+        return ((data[pos] & 0xff) << 24) | ((data[pos + 1] & 0xff) << 16)
+                | ((data[pos + 2] & 0xff) << 8) | (data[pos + 3] & 0xff);
+    }
+
+    private static void writeULong(byte[] data, int pos, int value) {
+        data[pos] = (byte) ((value >> 24) & 0xff);
+        data[pos + 1] = (byte) ((value >> 16) & 0xff);
+        data[pos + 2] = (byte) ((value >> 8) & 0xff);
+        data[pos + 3] = (byte) (value & 0xff);
+    }
+}
diff --git a/fop-core/src/main/java/org/apache/fop/render/afp/AFPDocumentHandler.java b/fop-core/src/main/java/org/apache/fop/render/afp/AFPDocumentHandler.java
index 0e13d8c8a..bcd213218 100644
--- a/fop-core/src/main/java/org/apache/fop/render/afp/AFPDocumentHandler.java
+++ b/fop-core/src/main/java/org/apache/fop/render/afp/AFPDocumentHandler.java
@@ -403,7 +403,7 @@ public class AFPDocumentHandler extends AbstractBinaryWritingIFDocumentHandler
             try {
                 getResourceManager().createIncludedResource(formMap.getName(),
                         formMap.getSrc(), accessor,
-                        ResourceObject.TYPE_FORMDEF, false, null);
+                        ResourceObject.TYPE_FORMDEF, false, null, null);
             } catch (IOException ioe) {
                 throw new IFException(
                         "I/O error while embedding form map resource: " + formMap.getName(), ioe);
diff --git a/fop-core/src/main/java/org/apache/fop/render/afp/AFPFontConfig.java b/fop-core/src/main/java/org/apache/fop/render/afp/AFPFontConfig.java
index 5230a5c0b..311ba5230 100644
--- a/fop-core/src/main/java/org/apache/fop/render/afp/AFPFontConfig.java
+++ b/fop-core/src/main/java/org/apache/fop/render/afp/AFPFontConfig.java
@@ -42,9 +42,12 @@ import org.apache.fop.afp.util.AFPResourceAccessor;
 import org.apache.fop.apps.FOPException;
 import org.apache.fop.apps.FOUserAgent;
 import org.apache.fop.apps.io.InternalResourceResolver;
+import org.apache.fop.complexscripts.fonts.Substitutable;
 import org.apache.fop.configuration.Configuration;
 import org.apache.fop.configuration.ConfigurationException;
 import org.apache.fop.events.EventProducer;
+import org.apache.fop.fonts.CMapSegment;
+import org.apache.fop.fonts.CustomFont;
 import org.apache.fop.fonts.EmbedFontInfo;
 import org.apache.fop.fonts.EmbeddingMode;
 import org.apache.fop.fonts.EncodingMode;
@@ -57,6 +60,7 @@ import org.apache.fop.fonts.FontType;
 import org.apache.fop.fonts.FontUris;
 import org.apache.fop.fonts.FontUtil;
 import org.apache.fop.fonts.LazyFont;
+import org.apache.fop.fonts.MultiByteFont;
 import org.apache.fop.fonts.Typeface;
 
 /**
@@ -388,12 +392,14 @@ public final class AFPFontConfig implements FontConfig {
                 FontUris fontUris = new FontUris(new URI(fontUri), null);
                 EmbedFontInfo embedFontInfo = new EmbedFontInfo(fontUris, false, true, null, subfont, EncodingMode.AUTO,
                         EmbeddingMode.FULL, false, false, true, false);
-                Typeface tf = new LazyFont(embedFontInfo, resourceResolver, false).getRealFont();
+                LazyFont lazyFont = new LazyFont(embedFontInfo, resourceResolver,
+                        userAgent.isComplexScriptFeaturesEnabled());
+                Typeface typeface = lazyFont.getRealFont();
                 AFPResourceAccessor accessor = getAccessor(resourceResolver);
                 CharacterSet characterSet = userAgent.getDoubleByteCharacterSetBuilder().build(characterset,
-                        super.codePage, super.encoding, tf, accessor, eventProducer);
+                        super.codePage, super.encoding, typeface, accessor, eventProducer);
                 OutlineFont font = new AFPTrueTypeFont(super.name, super.embeddable, characterSet,
-                            eventProducer, subfont, new URI(fontUri), positionByChar);
+                            eventProducer, subfont, new URI(fontUri), positionByChar, lazyFont);
                 return getFontInfo(font, this);
             } catch (URISyntaxException e) {
                 throw new IOException(e);
@@ -401,16 +407,18 @@ public final class AFPFontConfig implements FontConfig {
         }
     }
 
-    public static class AFPTrueTypeFont extends OutlineFont {
+    public static class AFPTrueTypeFont extends OutlineFont implements Substitutable {
         private String ttc;
         private URI uri;
         private boolean positionByChar;
+        private LazyFont lazyFont;
         public AFPTrueTypeFont(String name, boolean embeddable, CharacterSet charSet, AFPEventProducer eventProducer,
-                               String ttc, URI uri, boolean positionByChar) {
+                               String ttc, URI uri, boolean positionByChar, LazyFont lazyFont) {
             super(name, embeddable, charSet, eventProducer);
             this.ttc = ttc;
             this.uri = uri;
             this.positionByChar = positionByChar;
+            this.lazyFont = lazyFont;
         }
 
         public FontType getFontType() {
@@ -428,6 +436,29 @@ public final class AFPFontConfig implements FontConfig {
         public boolean isPositionByChar() {
             return positionByChar;
         }
+
+        public CMapSegment[] getCMap() {
+            return ((CustomFont) lazyFont.getRealFont()).getCMap();
+        }
+
+        public boolean hasPrivateUseSubstitutions() {
+            Typeface realFont = lazyFont.getRealFont();
+            return realFont instanceof MultiByteFont && ((MultiByteFont) realFont).hasPrivateUseSubstitutions();
+        }
+
+        public boolean performsSubstitution() {
+            return lazyFont.performsSubstitution();
+        }
+
+        public CharSequence performSubstitution(CharSequence cs, String script, String language, List associations,
+                                                boolean retainControls) {
+            return lazyFont.performSubstitution(cs, script, language, associations, retainControls);
+        }
+
+        public CharSequence reorderCombiningMarks(CharSequence cs, int[][] gpa, String script, String language,
+                                                  List associations) {
+            return lazyFont.reorderCombiningMarks(cs, gpa, script, language, associations);
+        }
     }
 
     static final class OutlineFontConfig extends AFPFontConfigData {
diff --git a/fop-core/src/test/java/org/apache/fop/afp/AFPTrailerFontTestCase.java b/fop-core/src/test/java/org/apache/fop/afp/AFPTrailerFontTestCase.java
new file mode 100644
index 000000000..29f925615
--- /dev/null
+++ b/fop-core/src/test/java/org/apache/fop/afp/AFPTrailerFontTestCase.java
@@ -0,0 +1,104 @@
+/*
+ * 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.afp;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.fontbox.ttf.CmapSubtable;
+import org.apache.fontbox.ttf.TTFParser;
+import org.apache.fontbox.ttf.TrueTypeCollection;
+import org.apache.fontbox.ttf.TrueTypeFont;
+import org.apache.pdfbox.io.RandomAccessReadBuffer;
+
+import org.apache.fop.fonts.CMapSegment;
+import org.apache.fop.fonts.truetype.CmapWriter;
+
+public class AFPTrailerFontTestCase {
+    private static final String FONT = "test/resources/fonts/ttf/DejaVuLGCSerif.ttf";
+
+    @Test
+    public void testExtractFullFontPreservesGlyphs() throws Exception {
+        TrueTypeFont original = new TTFParser().parse(new RandomAccessReadBuffer(new FileInputStream(FONT)));
+        TrueTypeFont extracted = extractFromTTC(wrapInTTC(new File(FONT)));
+        Assert.assertEquals(original.getNumberOfGlyphs(), extracted.getNumberOfGlyphs());
+        Assert.assertEquals(original.getTables().size(), extracted.getTables().size());
+        Assert.assertEquals(original.getPath("A").getBounds(), extracted.getPath("A").getBounds());
+    }
+
+    @Test
+    public void testAppendedCmapResolvesPuaToOriginalGlyph() throws Exception {
+        int gidOfA = new TTFParser().parse(new RandomAccessReadBuffer(new FileInputStream(FONT)))
+                .getCmap().getSubtable(3, 1).getGlyphId('A');
+        byte[] fontData = AFPTrailerFont.extractFullFont(extractFromTTC(wrapInTTC(new File(FONT))));
+        fontData = CmapWriter.appendCmap(fontData, new CMapSegment[] {new CMapSegment(0xE000, 0xE000, gidOfA)});
+        TrueTypeFont withPua = new TTFParser().parse(new RandomAccessReadBuffer(new ByteArrayInputStream(fontData)));
+        CmapSubtable subtable = withPua.getCmap().getSubtable(3, 1);
+        Assert.assertEquals(gidOfA, subtable.getGlyphId(0xE000));
+    }
+
+    private TrueTypeFont extractFromTTC(byte[] ttc) throws Exception {
+        TrueTypeCollection collection = new TrueTypeCollection(new ByteArrayInputStream(ttc));
+        TrueTypeFont font = collection.getFontByName("DejaVuLGCSerif");
+        return new TTFParser().parse(new RandomAccessReadBuffer(new ByteArrayInputStream(
+                AFPTrailerFont.extractFullFont(font))));
+    }
+
+    private byte[] wrapInTTC(File ttf) throws Exception {
+        byte[] sfnt = IOUtils.toByteArray(ttf.toURI().toURL());
+        int header = 16;
+        byte[] ttc = new byte[header + sfnt.length];
+        ttc[0] = 't';
+        ttc[1] = 't';
+        ttc[2] = 'c';
+        ttc[3] = 'f';
+        writeUInt32(ttc, 4, 0x00010000);   // version 1.0
+        writeUInt32(ttc, 8, 1);            // numFonts
+        writeUInt32(ttc, 12, header);      // offset to font 0 directory
+        System.arraycopy(sfnt, 0, ttc, header, sfnt.length);
+        int numTables = readUInt16(ttc, header + 4);
+        for (int i = 0; i < numTables; i++) {
+            int offsetField = header + 12 + i * 16 + 8;
+            writeUInt32(ttc, offsetField, readUInt32(ttc, offsetField) + header);
+        }
+        return ttc;
+    }
+
+    private static int readUInt16(byte[] data, int pos) {
+        return ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+    }
+
+    private static int readUInt32(byte[] data, int pos) {
+        return ((data[pos] & 0xff) << 24) | ((data[pos + 1] & 0xff) << 16)
+                | ((data[pos + 2] & 0xff) << 8) | (data[pos + 3] & 0xff);
+    }
+
+    private static void writeUInt32(byte[] data, int pos, int value) {
+        data[pos] = (byte) ((value >> 24) & 0xff);
+        data[pos + 1] = (byte) ((value >> 16) & 0xff);
+        data[pos + 2] = (byte) ((value >> 8) & 0xff);
+        data[pos + 3] = (byte) (value & 0xff);
+    }
+}
diff --git a/fop-core/src/test/java/org/apache/fop/fonts/truetype/CmapWriterTestCase.java b/fop-core/src/test/java/org/apache/fop/fonts/truetype/CmapWriterTestCase.java
new file mode 100644
index 000000000..8d6d54316
--- /dev/null
+++ b/fop-core/src/test/java/org/apache/fop/fonts/truetype/CmapWriterTestCase.java
@@ -0,0 +1,81 @@
+/*
+ * 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.truetype;
+
+import java.io.ByteArrayInputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.apache.commons.io.IOUtils;
+
+import org.apache.fop.fonts.CMapSegment;
+
+public class CmapWriterTestCase {
+
+    private static final String FONT = "test/resources/fonts/ttf/DejaVuLGCSerif.ttf";
+
+    @Test
+    public void testAppendCmapResolvesPrivateUseMappings() throws IOException {
+        byte[] original = readFont();
+        List<CMapSegment> originalCmap = loadCmap(original);
+        int gidForA = lookup(originalCmap, 'A');
+        int gidForB = lookup(originalCmap, 'B');
+        assertTrue("test font must map 'A'", gidForA > 0);
+        assertTrue("test font must map 'B'", gidForB > 0);
+        List<CMapSegment> augmented = new ArrayList<>(originalCmap);
+        augmented.add(new CMapSegment(0xE000, 0xE000, gidForA));
+        augmented.add(new CMapSegment(0xE001, 0xE001, gidForB));
+        byte[] rewritten = CmapWriter.appendCmap(original, augmented.toArray(new CMapSegment[0]));
+        List<CMapSegment> rewrittenCmap = loadCmap(rewritten);
+        assertEquals(gidForA, lookup(rewrittenCmap, 0xE000));
+        assertEquals(gidForB, lookup(rewrittenCmap, 0xE001));
+        assertEquals(gidForA, lookup(rewrittenCmap, 'A'));
+        assertEquals(gidForB, lookup(rewrittenCmap, 'B'));
+    }
+
+    private byte[] readFont() throws IOException {
+        try (InputStream is = new FileInputStream(FONT)) {
+            return IOUtils.toByteArray(is);
+        }
+    }
+
+    private List<CMapSegment> loadCmap(byte[] font) throws IOException {
+        FontFileReader reader = new FontFileReader(new ByteArrayInputStream(font));
+        TTFFile ttf = new TTFFile();
+        ttf.readFont(reader, OFFontLoader.readHeader(reader));
+        return ttf.getCMaps();
+    }
+
+    private int lookup(List<CMapSegment> cmap, int codePoint) {
+        for (CMapSegment segment : cmap) {
+            if (codePoint >= segment.getUnicodeStart() && codePoint <= segment.getUnicodeEnd()) {
+                return segment.getGlyphStartIndex() + (codePoint - segment.getUnicodeStart());
+            }
+        }
+        return 0;
+    }
+}
diff --git a/fop-core/src/test/java/org/apache/fop/render/afp/AFPTrueTypeTestCase.java b/fop-core/src/test/java/org/apache/fop/render/afp/AFPTrueTypeTestCase.java
index 1575f03b3..b7c16099d 100644
--- a/fop-core/src/test/java/org/apache/fop/render/afp/AFPTrueTypeTestCase.java
+++ b/fop-core/src/test/java/org/apache/fop/render/afp/AFPTrueTypeTestCase.java
@@ -27,6 +27,8 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
 
 import javax.xml.transform.Result;
 import javax.xml.transform.Source;
@@ -37,6 +39,10 @@ import javax.xml.transform.stream.StreamSource;
 
 import org.junit.Assert;
 import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -57,10 +63,13 @@ import org.apache.fop.apps.Fop;
 import org.apache.fop.apps.FopConfParser;
 import org.apache.fop.apps.FopFactory;
 import org.apache.fop.apps.FopFactoryBuilder;
+import org.apache.fop.apps.io.InternalResourceResolver;
 import org.apache.fop.apps.io.ResourceResolverFactory;
+import org.apache.fop.complexscripts.fonts.ttx.TTXFile;
 import org.apache.fop.fonts.EmbeddingMode;
 import org.apache.fop.fonts.FontInfo;
 import org.apache.fop.fonts.FontTriplet;
+import org.apache.fop.fonts.LazyFont;
 import org.apache.fop.fonts.MultiByteFont;
 import org.apache.fop.render.intermediate.IFException;
 
@@ -264,6 +273,48 @@ public class AFPTrueTypeTestCase {
         Assert.assertEquals(bis.readShort(), 5);
     }
 
+    @Test
+    public void testArabicComplexScriptToAFP() {
+        TTXFile ttx = TTXFile.getFromCache("test/resources/complexscripts/arab/ttx/arab-002.ttx");
+        final MyMultiByteFont multiByteFont = new MyMultiByteFont(null, EmbeddingMode.AUTO);
+        multiByteFont.setGSUB(ttx.getGSUB());
+        for (int cp = 0x20; cp <= 0x06FF; cp++) {
+            int gi = ttx.mapCharToGlyph(cp);
+            if (gi > 0) {
+                multiByteFont.callAddPrivateUseMapping(cp, gi);
+            }
+        }
+        multiByteFont.setWidthArray(ttx.getWidths());
+        LazyFont lazyFont = mock(LazyFont.class);
+        when(lazyFont.performsSubstitution()).thenReturn(multiByteFont.performsSubstitution());
+        when(lazyFont.performSubstitution(any(CharSequence.class), any(String.class), any(String.class),
+                any(List.class), anyBoolean())).thenAnswer(
+                new Answer<CharSequence>() {
+                    public CharSequence answer(InvocationOnMock inv) {
+                        Object[] a = inv.getArguments();
+                        return multiByteFont.performSubstitution((CharSequence) a[0], (String) a[1],
+                                (String) a[2], (List) a[3], (Boolean) a[4]);
+                    }
+                });
+        FopCharacterSet characterSet = new FopCharacterSet("", "UTF-16BE", "", multiByteFont, null, null);
+        AFPFontConfig.AFPTrueTypeFont afpFont = new AFPFontConfig.AFPTrueTypeFont("", true,
+                characterSet, null, null, null, false, lazyFont);
+        String arabic = "\u0645\u0643\u062A\u0628";
+        CharSequence shaped = afpFont.performSubstitution(arabic, "arab", "dflt", new ArrayList(), false);
+        Assert.assertTrue(multiByteFont.hasPrivateUseSubstitutions());
+        Assert.assertEquals("\uE000\uE001\uE002\uE003", shaped.toString());
+    }
+
+    static class MyMultiByteFont extends MultiByteFont {
+        MyMultiByteFont(InternalResourceResolver resourceResolver, EmbeddingMode embeddingMode) {
+            super(resourceResolver, embeddingMode);
+        }
+
+        void callAddPrivateUseMapping(int pu, int gi) {
+            addPrivateUseMapping(pu, gi);
+        }
+    }
+
     private String getAFP(String fo) throws Exception {
         ByteArrayOutputStream bos = getAFPBytes(fo);
         StringBuilder sb = new StringBuilder();
@@ -410,7 +461,7 @@ public class AFPTrueTypeTestCase {
             }
             font.setWidthArray(widths);
             f.addMetrics("any", new AFPFontConfig.AFPTrueTypeFont("", true,
-                    new FopCharacterSet("", "UTF-16BE", "", font, null, null), null, null, null, positionByChar));
+                    new FopCharacterSet("", "UTF-16BE", "", font, null, null), null, null, null, positionByChar, null));
             return f;
         }
     }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@xmlgraphics.apache.org
For additional commands, e-mail: commits-help@xmlgraphics.apache.org