diff --git a/debugger/src/main/java/org/apache/pdfbox/debugger/pagepane/PagePane.java b/debugger/src/main/java/org/apache/pdfbox/debugger/pagepane/PagePane.java index ddcbb5459b0..831a4c60dd9 100644 --- a/debugger/src/main/java/org/apache/pdfbox/debugger/pagepane/PagePane.java +++ b/debugger/src/main/java/org/apache/pdfbox/debugger/pagepane/PagePane.java @@ -95,7 +95,7 @@ /** * Display the page number and a page rendering. - * + * * @author Tilman Hausherr * @author John Hewson */ @@ -298,7 +298,7 @@ private void initUI() pageLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 20)); pageLabel.setBorder(BorderFactory.createEmptyBorder(5, 0, 10, 0)); panel.add(pageLabel); - + label = new JLabel(); label.addMouseMotionListener(this); label.addMouseListener(this); @@ -395,7 +395,7 @@ public void ancestorAdded(AncestorEvent ancestorEvent) { zoomMenu.addMenuListeners(this); zoomMenu.setEnableMenu(true); - + rotationMenu = RotationMenu.getInstance(); rotationMenu.addMenuListeners(this); rotationMenu.setEnableMenu(true); @@ -412,7 +412,7 @@ public void ancestorAdded(AncestorEvent ancestorEvent) JMenu menuInstance = viewMenu.getMenu(); int itemCount = menuInstance.getItemCount(); - + for (int i = 0; i< itemCount; i++) { JMenuItem item = menuInstance.getItem(i); @@ -435,7 +435,7 @@ public void ancestorRemoved(AncestorEvent ancestorEvent) JMenu menuInstance = viewMenu.getMenu(); int itemCount = menuInstance.getItemCount(); - + for (int i = 0; i< itemCount; i++) { JMenuItem item = menuInstance.getItem(i); @@ -705,7 +705,7 @@ protected BufferedImage doInBackground() throws IOException PDFRenderer renderer = new PDFRenderer(document); renderer.setSubsamplingAllowed(ViewMenu.isAllowSubsampling()); - //renderer.setHintingEnabled(ViewMenu.isHintingEnabled()) + renderer.setHintingEnabled(ViewMenu.isHintingEnabled()); long t0 = System.nanoTime(); BufferedImage image = renderer.renderImage(pageIndex, scale, ImageTypeMenu.getImageType(), RenderDestinationMenu.getRenderDestination()); @@ -716,13 +716,13 @@ protected BufferedImage doInBackground() throws IOException statuslabel.setText(labelText); // debug overlays - DebugTextOverlay debugText = new DebugTextOverlay(document, pageIndex, scale, + DebugTextOverlay debugText = new DebugTextOverlay(document, pageIndex, scale, showTextStripper, showTextStripperBeads, showFontBBox, ViewMenu.isShowGlyphBounds()); Graphics2D g = image.createGraphics(); debugText.renderTo(g); g.dispose(); - + return ImageUtil.getRotatedImage(image, rotation); } @@ -733,12 +733,12 @@ protected void done() { BufferedImage image = get(); - // We cannot use "label.setIcon(new ImageIcon(get()))" here - // because of blurry upscaling in JDK9. Instead, the label is now created with + // We cannot use "label.setIcon(new ImageIcon(get()))" here + // because of blurry upscaling in JDK9. Instead, the label is now created with // a smaller size than the image to compensate that the // image is scaled up with some screen configurations (e.g. 125% on windows). // See PDFBOX-3665 for more sample code and discussion. - label.setSize((int) Math.ceil(image.getWidth() / defaultTransform.getScaleX()), + label.setSize((int) Math.ceil(image.getWidth() / defaultTransform.getScaleX()), (int) Math.ceil(image.getHeight() / defaultTransform.getScaleY())); label.setIcon(new HighResolutionImageIcon(image, label.getWidth(), label.getHeight())); label.setText(null); diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/BytecodeStream.java b/fontbox/src/main/java/org/apache/fontbox/ttf/BytecodeStream.java new file mode 100644 index 00000000000..80c740f4bf1 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/BytecodeStream.java @@ -0,0 +1,140 @@ +/* + * 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.fontbox.ttf; + +/** + * A bounds-checked cursor over a TrueType bytecode program (fpgm, prep, a glyph instruction stream, or + * a function body). It owns the program counter so individual opcode handlers never index the array by + * hand - this removes a whole class of off-by-one and overrun bugs and is trivially testable on its + * own. Reads past the end throw {@link HintingException} so the per-glyph fallback can catch cleanly. + * + * @author Apache PDFBox + */ +class BytecodeStream +{ + private final byte[] code; + private int ip; + private int instructionStart; + + /** + * @param code the bytecode program; not copied + */ + public BytecodeStream(byte[] code) + { + this.code = code != null ? code : new byte[0]; + } + + /** + * @return the underlying bytecode array (not copied); used to record function entry points + */ + public byte[] getCode() + { + return code; + } + + /** + * @return true if there is at least one more byte to read + */ + public boolean hasNext() + { + return ip < code.length; + } + + /** + * @return the current program-counter position + */ + public int position() + { + return ip; + } + + /** + * Records the current position as the start of the instruction about to be read. Relative jumps + * ({@code JMPR}/{@code JROT}/{@code JROF}) are measured from here. + */ + public void markInstructionStart() + { + instructionStart = ip; + } + + /** + * @return the position recorded by the most recent {@link #markInstructionStart()} + */ + public int instructionStart() + { + return instructionStart; + } + + /** + * Moves the program counter to an absolute position. + * + * @param position the new position, within {@code [0, length]} + * @throws HintingException if the position is out of range + */ + public void seek(int position) + { + if (position < 0 || position > code.length) + { + throw new HintingException( + "bytecode seek out of range: " + position + " of " + code.length); + } + ip = position; + } + + /** + * Advances the program counter by a relative amount (may be negative). + * + * @param delta the number of bytes to skip + * @throws HintingException if the result is out of range + */ + public void skip(int delta) + { + seek(ip + delta); + } + + /** + * Reads the next byte as an unsigned 0-255 value (an opcode, or push operand). + * + * @return the next unsigned byte + * @throws HintingException if the stream is exhausted + */ + public int nextByte() + { + if (ip >= code.length) + { + throw new HintingException("bytecode read past end at " + ip); + } + return code[ip++] & 0xFF; + } + + /** + * Reads the next two bytes as a signed big-endian 16-bit word. + * + * @return the next signed word + * @throws HintingException if fewer than two bytes remain + */ + public int nextWord() + { + if (ip + 1 >= code.length) + { + throw new HintingException("bytecode word read past end at " + ip); + } + int hi = code[ip++] & 0xFF; + int lo = code[ip++] & 0xFF; + return (short) ((hi << 8) | lo); + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/ControlValueProgramTable.java b/fontbox/src/main/java/org/apache/fontbox/ttf/ControlValueProgramTable.java new file mode 100644 index 00000000000..e5c3ae48e03 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/ControlValueProgramTable.java @@ -0,0 +1,65 @@ +/* + * 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.fontbox.ttf; + +import java.io.IOException; + +/** + * The 'prep' (Control Value Program, also known as the pre-program) table. It holds TrueType hinting + * bytecode that is executed whenever the point size or transform changes, to prepare the Control Value + * Table and graphics state for the new ppem. The bytecode is stored here as raw bytes and run by the + * interpreter. + * + * @author Apache PDFBox + */ +public class ControlValueProgramTable extends TTFTable +{ + /** + * A tag that identifies this table type. + */ + public static final String TAG = "prep"; + + private byte[] program; + + ControlValueProgramTable() + { + } + + /** + * This will read the required data from the stream. + * + * @param ttf The font that is being read. + * @param data The stream to read the data from. + * @throws IOException If there is an error reading the data. + */ + @Override + void read(TrueTypeFont ttf, TTFDataStream data) throws IOException + { + program = data.read((int) getLength()); + initialized = true; + } + + /** + * Returns the raw control value program bytecode. + * + * @return the bytecode of the pre-program + */ + public byte[] getProgram() + { + return program; + } +} \ No newline at end of file diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/ControlValueTable.java b/fontbox/src/main/java/org/apache/fontbox/ttf/ControlValueTable.java new file mode 100644 index 00000000000..6d87318ac7a --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/ControlValueTable.java @@ -0,0 +1,82 @@ +/* + * 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.fontbox.ttf; + +import java.io.IOException; + +/** + * The 'cvt ' (Control Value) table. It holds an array of reference values - stem widths, heights and + * similar control measurements - used by the TrueType hinting bytecode. The values are stored here in + * raw font units (signed FWords); they are scaled to the active ppem by the interpreter, not at parse + * time. + * + * @author Apache PDFBox + */ +public class ControlValueTable extends TTFTable +{ + /** + * A tag that identifies this table type. + */ + public static final String TAG = "cvt "; + + private int[] values; + + ControlValueTable() + { + } + + /** + * This will read the required data from the stream. + * + * @param ttf The font that is being read. + * @param data The stream to read the data from. + * @throws IOException If there is an error reading the data. + */ + @Override + void read(TrueTypeFont ttf, TTFDataStream data) throws IOException + { + int count = (int) (getLength() / 2); + int[] cvt = new int[count]; + for (int i = 0; i < count; i++) + { + cvt[i] = data.readSignedShort(); + } + values = cvt; + initialized = true; + } + + /** + * Returns the raw control values in font units (FWords). The interpreter scales these to the + * active ppem. + * + * @return the control values in font units + */ + public int[] getValues() + { + return values; + } + + /** + * Returns the number of control values in this table. + * + * @return the entry count + */ + public int getValueCount() + { + return values != null ? values.length : 0; + } +} \ No newline at end of file diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/ExecutionContext.java b/fontbox/src/main/java/org/apache/fontbox/ttf/ExecutionContext.java new file mode 100644 index 00000000000..63f6c8bd5f4 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/ExecutionContext.java @@ -0,0 +1,504 @@ +/* + * 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.fontbox.ttf; + +/** + * All mutable state for a single run of the interpreter, bundled into one object so opcode handlers + * share a uniform {@code execute(ExecutionContext)} signature and new state can be added without + * touching every handler. It holds the operand stack, storage area, scaled control values, the two + * point zones, the {@link GraphicsState}, the current {@link BytecodeStream}, and the ppem the program + * is running at. + *

+ * Not all of that state is per-run. The storage area and the twilight zone are owned by the + * {@link TrueTypeInterpreter} and handed to every context at one size, because a font may compute values + * into them in {@code prep} and read them back from each glyph program - FreeType keeps both on the + * {@code TT_Size} for the same reason. + * + * @author Apache PDFBox + */ +class ExecutionContext +{ + private final TrueTypeInterpreter interpreter; + private final GraphicsState graphicsState; + + private final int[] stack; + private int stackPointer; + + // owned by the interpreter and shared by every context at one size, so prep can seed them + private final int[] storage; + private final Zone twilightZone; + + private final int[] controlValues; + private Zone glyphZone; + + private int ppem; + private int pointSize; + private int unitsPerEm; + + private BytecodeStream stream; + private int callDepth; + private boolean returnFromFunction; + + // Execution budget. TrueType can only loop through a backward jump or a LOOPCALL, so bounding + // those two bounds the whole program - without them a four-byte glyph program can spin forever. + // Both counters are per-context, and one context is one top-level program run, so they need no + // reset. After FreeType's neg_jump_counter / loopcall_counter in TT_RunIns. + private long negativeJumpCounter; + private long loopCallCounter; + private int executionBudget = -1; + + // v40 "backward compatibility" (grayscale subpixel) state. When set, point moves in the x + // direction are suppressed so stems are not grid-fit and darkened under antialiasing, and y moves + // are frozen once IUP has run on both axes. Only enabled for the glyph program, never fpgm/prep. + private boolean backwardCompatibility; + private boolean iupxCalled; + private boolean iupyCalled; + private boolean composite; + + /** + * @param interpreter the owning interpreter (for function calls) + * @param graphicsState the graphics state this run starts from + * @param maxStackElements operand stack capacity + * @param storage the interpreter's storage area, shared across the runs at one size + * @param controlValues the scaled control values (F26Dot6), or null + * @param twilightZone the interpreter's twilight zone, shared across the runs at one size + */ + public ExecutionContext(TrueTypeInterpreter interpreter, GraphicsState graphicsState, + int maxStackElements, int[] storage, int[] controlValues, Zone twilightZone) + { + this.interpreter = interpreter; + this.graphicsState = graphicsState; + this.stack = new int[Math.max(maxStackElements, 1)]; + this.storage = storage; + this.controlValues = controlValues != null ? controlValues : new int[0]; + this.twilightZone = twilightZone; + } + + /** @return the owning interpreter */ + public TrueTypeInterpreter getInterpreter() + { + return interpreter; + } + + /** @return the graphics state */ + public GraphicsState getGraphicsState() + { + return graphicsState; + } + + // --- operand stack --------------------------------------------------- + + /** + * Pushes a value onto the operand stack. + * + * @param value the value to push + * @throws HintingException on stack overflow + */ + public void push(int value) + { + if (stackPointer >= stack.length) + { + throw new HintingException("interpreter stack overflow at " + stackPointer); + } + stack[stackPointer++] = value; + } + + /** + * Pops a value from the operand stack. + * + * @return the popped value + * @throws HintingException on stack underflow + */ + public int pop() + { + if (stackPointer <= 0) + { + throw new HintingException("interpreter stack underflow"); + } + return stack[--stackPointer]; + } + + /** + * Returns the value {@code n} positions below the top without removing it ({@code peek(0)} is the + * top of stack). + * + * @param n depth below the top + * @return the value at that depth + * @throws HintingException if the depth is out of range + */ + public int peek(int n) + { + int index = stackPointer - 1 - n; + if (index < 0 || index >= stackPointer) + { + throw new HintingException("interpreter stack peek out of range: " + n); + } + return stack[index]; + } + + /** @return the current stack depth */ + public int getStackDepth() + { + return stackPointer; + } + + /** Empties the operand stack. */ + public void clearStack() + { + stackPointer = 0; + } + + // --- execution budget ------------------------------------------------- + + /** + * The maximum number of backward jumps, and separately of {@code LOOPCALL} iterations, this run may + * make before it is abandoned. Sized from the glyph's point count and the control value count the + * way FreeType sizes its counters, so a legitimately loop-heavy program still completes while a + * crafted one cannot run forever. Computed on first use, because the glyph zone is attached after + * the context is built. + * + * @return the per-run budget + */ + public int getExecutionBudget() + { + if (executionBudget < 0) + { + int points = glyphZone != null ? glyphZone.getPointCount() : 0; + executionBudget = Math.max(50, 10 * points) + Math.max(50, controlValues.length / 10); + } + return executionBudget; + } + + /** + * Records one backward jump, failing the run once {@link #getExecutionBudget()} is exhausted. + * + * @throws HintingException if too many backward jumps have been made + */ + public void countNegativeJump() + { + if (++negativeJumpCounter > getExecutionBudget()) + { + throw new HintingException( + "too many backward jumps, limit is " + getExecutionBudget()); + } + } + + /** + * Adds {@code count} iterations to the {@code LOOPCALL} budget, failing before the loop is entered + * rather than partway through it. The budget is cumulative across the run, so a program cannot slip + * past it by issuing many small loops. + * + * @param count the number of iterations about to be run, always positive + * @throws HintingException if the budget is exhausted + */ + public void countLoopCalls(int count) + { + loopCallCounter += count; + if (loopCallCounter > getExecutionBudget()) + { + throw new HintingException("LOOPCALL runs too long, limit is " + getExecutionBudget() + + " iterations, asked for " + loopCallCounter); + } + } + + // --- storage and control values -------------------------------------- + + /** @return the storage area, shared with every other run at this size */ + public int[] getStorage() + { + return storage; + } + + /** @return the scaled control values in F26Dot6 */ + public int[] getControlValues() + { + return controlValues; + } + + // --- zones ----------------------------------------------------------- + + /** @return the twilight zone (zone 0), shared with every other run at this size */ + public Zone getTwilightZone() + { + return twilightZone; + } + + /** @return the glyph zone (zone 1), or null if no glyph is loaded */ + public Zone getGlyphZone() + { + return glyphZone; + } + + /** @param zone the glyph zone (zone 1) */ + public void setGlyphZone(Zone zone) + { + this.glyphZone = zone; + } + + /** + * Resolves a zone pointer (0 = twilight, 1 = glyph) to its {@link Zone}. + * + * @param zonePointer the zone pointer value + * @return the corresponding zone + * @throws HintingException if the pointer is invalid or the glyph zone is unset + */ + public Zone getZone(int zonePointer) + { + if (zonePointer == 0) + { + return twilightZone; + } + if (zonePointer == 1) + { + if (glyphZone == null) + { + throw new HintingException("glyph zone referenced but not loaded"); + } + return glyphZone; + } + throw new HintingException("invalid zone pointer: " + zonePointer); + } + + // --- sizing ---------------------------------------------------------- + + /** @return the active pixels-per-em */ + public int getPpem() + { + return ppem; + } + + /** @param value the active pixels-per-em */ + public void setPpem(int value) + { + this.ppem = value; + } + + /** @return the point size */ + public int getPointSize() + { + return pointSize; + } + + /** @param value the point size */ + public void setPointSize(int value) + { + this.pointSize = value; + } + + /** @return the font's unitsPerEm */ + public int getUnitsPerEm() + { + return unitsPerEm; + } + + /** @param value the font's unitsPerEm */ + public void setUnitsPerEm(int value) + { + this.unitsPerEm = value; + } + + // --- projection / freedom vector math -------------------------------- + + /** + * Dot product of two F2Dot14 vectors (or a coordinate against an F2Dot14 vector), returning the + * result shifted back down by 14 bits with rounding. This is the projection primitive: projecting + * an F26Dot6 coordinate onto an F2Dot14 unit vector yields an F26Dot6 distance. + * + * @param ax first vector x + * @param ay first vector y + * @param bx second vector x (F2Dot14) + * @param by second vector y (F2Dot14) + * @return the rounded dot product + */ + public static int dot14(int ax, int ay, int bx, int by) + { + long product = (long) ax * bx + (long) ay * by; + return (int) ((product + 0x2000) >> 14); + } + + /** + * Projects a coordinate onto the projection vector. + * + * @param x the x coordinate in F26Dot6 + * @param y the y coordinate in F26Dot6 + * @return the projected distance in F26Dot6 + */ + public int project(int x, int y) + { + UnitVector pv = graphicsState.getProjectionVector(); + return dot14(x, y, pv.getX(), pv.getY()); + } + + /** + * Projects a coordinate onto the dual projection vector (used to measure original, unhinted + * positions). + * + * @param x the x coordinate in F26Dot6 + * @param y the y coordinate in F26Dot6 + * @return the projected distance in F26Dot6 + */ + public int dualProject(int x, int y) + { + UnitVector dv = graphicsState.getDualProjectionVector(); + return dot14(x, y, dv.getX(), dv.getY()); + } + + /** + * Returns the current (hinted) projected distance from point {@code p0} in {@code zone0} to point + * {@code p1} in {@code zone1}, measured along the projection vector. + */ + public int projectedDistance(Zone zone1, int p1, Zone zone0, int p0) + { + return project(zone1.getCurrentX()[p1] - zone0.getCurrentX()[p0], + zone1.getCurrentY()[p1] - zone0.getCurrentY()[p0]); + } + + /** + * Returns the original (unhinted) projected distance from point {@code p0} in {@code zone0} to + * point {@code p1} in {@code zone1}, measured along the dual projection vector. + */ + public int dualProjectedDistance(Zone zone1, int p1, Zone zone0, int p0) + { + return dualProject(zone1.getOriginalX()[p1] - zone0.getOriginalX()[p0], + zone1.getOriginalY()[p1] - zone0.getOriginalY()[p0]); + } + + /** + * Moves a point by the given projected distance along the freedom vector, touching the axes the + * freedom vector acts on. The displacement is {@code distance * freedom / (freedom . projection)}, + * which reduces to {@code distance} when both vectors are the same axis. + * + * @param zone the zone holding the point + * @param point the point index + * @param distance the projected distance to move, in F26Dot6 + */ + public void movePoint(Zone zone, int point, int distance) + { + UnitVector fv = graphicsState.getFreedomVector(); + UnitVector pv = graphicsState.getProjectionVector(); + int fDotP = dot14(fv.getX(), fv.getY(), pv.getX(), pv.getY()); + if (fDotP == 0) + { + fDotP = Fixed.ONE_F2DOT14; + } + if (fv.getX() != 0) + { + // backward-compatibility (v40 grayscale): never grid-fit in the x direction, so horizontal + // stems keep their natural sub-pixel position and are not darkened by antialiasing + if (!backwardCompatibility) + { + zone.getCurrentX()[point] += Fixed.mulDiv(distance, fv.getX(), fDotP); + } + zone.getTouchedX()[point] = true; + } + if (fv.getY() != 0) + { + // y moves are allowed until IUP has run on both axes; afterwards the glyph is frozen + if (!(backwardCompatibility && iupxCalled && iupyCalled)) + { + zone.getCurrentY()[point] += Fixed.mulDiv(distance, fv.getY(), fDotP); + } + zone.getTouchedY()[point] = true; + } + } + + /** @return whether v40 backward-compatibility (grayscale subpixel) movement rules are active */ + public boolean isBackwardCompatibility() + { + return backwardCompatibility; + } + + /** @param value whether to apply v40 backward-compatibility movement rules (glyph program only) */ + public void setBackwardCompatibility(boolean value) + { + this.backwardCompatibility = value; + } + + /** Marks IUP[x] as having run; resets each program run. */ + public void setIupxCalled() + { + this.iupxCalled = true; + } + + /** Marks IUP[y] as having run; resets each program run. */ + public void setIupyCalled() + { + this.iupyCalled = true; + } + + /** @return whether IUP has run on both axes (the glyph is frozen for backward compatibility) */ + public boolean isIupDone() + { + return iupxCalled && iupyCalled; + } + + /** @return whether the running program belongs to a composite glyph */ + public boolean isComposite() + { + return composite; + } + + /** @param value whether the running program belongs to a composite glyph */ + public void setComposite(boolean value) + { + this.composite = value; + } + + // --- execution cursor and call state --------------------------------- + + /** @return the current bytecode stream */ + public BytecodeStream getStream() + { + return stream; + } + + /** @param stream the current bytecode stream */ + public void setStream(BytecodeStream stream) + { + this.stream = stream; + } + + /** @return the current call nesting depth */ + public int getCallDepth() + { + return callDepth; + } + + /** Increments the call nesting depth. */ + public void enterCall() + { + callDepth++; + } + + /** Decrements the call nesting depth. */ + public void leaveCall() + { + callDepth--; + } + + /** @return true if an {@code ENDF} asked the current function body to return */ + public boolean isReturnFromFunction() + { + return returnFromFunction; + } + + /** @param value whether the current function body should return */ + public void setReturnFromFunction(boolean value) + { + this.returnFromFunction = value; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/ExecutionTracer.java b/fontbox/src/main/java/org/apache/fontbox/ttf/ExecutionTracer.java new file mode 100644 index 00000000000..fc1b0c80cc0 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/ExecutionTracer.java @@ -0,0 +1,269 @@ +/* + * 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.fontbox.ttf; + +import java.io.PrintStream; + +/** + * A first-class, toggleable execution tracer for the interpreter. When attached to a + * {@link TrueTypeInterpreter} it emits one line per executed instruction, just before the instruction + * runs, in a format deliberately close to FreeType's {@code FT2_DEBUG=ttinterp} output: + * + *

+ *   <pc>  <MNEMONIC>  # <top of stack, deepest..top>
+ * 
+ * + * The program counter and the operand stack are what matter for diffing: feeding the same glyph + * through this tracer and through FreeType and aligning the two traces by instruction index pinpoints + * the first instruction where control flow or an operand value diverges. See + * {@code src/test/resources/ttf/hinting/trace_diff.py} for the diff tool. + * + * @author Apache PDFBox + */ +class ExecutionTracer +{ + private static final String[] MNEMONICS = buildMnemonics(); + + /** Number of top-of-stack operands to print, matching FreeType's trace window. */ + private static final int STACK_WINDOW = 8; + + private final PrintStream out; + private final int tracePoint; + + /** + * @param out where to write trace lines + */ + public ExecutionTracer(PrintStream out) + { + this(out, -1); + } + + /** + * @param out where to write trace lines + * @param tracePoint a glyph-zone point index whose current coordinate is appended to each line + * (for localizing silent point-position divergence), or -1 to omit + */ + public ExecutionTracer(PrintStream out, int tracePoint) + { + this.out = out; + this.tracePoint = tracePoint; + } + + /** + * @param opcode an opcode value 0-255 + * @return the mnemonic for that opcode + */ + public static String mnemonic(int opcode) + { + return MNEMONICS[opcode & 0xFF]; + } + + /** + * Emits a trace line for the instruction about to execute. + * + * @param pc the program-counter position of the instruction + * @param opcode the opcode about to run + * @param ctx the execution context (its stack is sampled) + */ + void trace(int pc, int opcode, ExecutionContext ctx) + { + StringBuilder sb = new StringBuilder(); + sb.append(String.format("%06d %-11s", pc, MNEMONICS[opcode & 0xFF])); + if (tracePoint >= 0) + { + Zone zone = ctx.getGlyphZone(); + if (zone != null && tracePoint < zone.getPointCount()) + { + sb.append(String.format(" P%d=(%d,%d)", tracePoint, + zone.getCurrentX()[tracePoint], zone.getCurrentY()[tracePoint])); + } + } + sb.append(" #"); + // top of stack first, matching FreeType's ttinterp window + int window = Math.min(STACK_WINDOW, ctx.getStackDepth()); + for (int k = 0; k < window; k++) + { + sb.append(' ').append(ctx.peek(k)); + } + out.println(sb); + } + + private static String[] buildMnemonics() + { + String[] m = new String[256]; + for (int i = 0; i < 256; i++) + { + m[i] = String.format("INS_%02X", i); + } + // axis-variant vector setters: [y] for the even (0) code, [x] for the odd (1) code + put(m, 0x00, "SVTCA[y]"); + put(m, 0x01, "SVTCA[x]"); + put(m, 0x02, "SPVTCA[y]"); + put(m, 0x03, "SPVTCA[x]"); + put(m, 0x04, "SFVTCA[y]"); + put(m, 0x05, "SFVTCA[x]"); + put(m, 0x06, "SPVTL[||]"); + put(m, 0x07, "SPVTL[+]"); + put(m, 0x08, "SFVTL[||]"); + put(m, 0x09, "SFVTL[+]"); + put(m, 0x0A, "SPVFS"); + put(m, 0x0B, "SFVFS"); + put(m, 0x0C, "GPV"); + put(m, 0x0D, "GFV"); + put(m, 0x0E, "SFVTPV"); + put(m, 0x0F, "ISECT"); + put(m, 0x10, "SRP0"); + put(m, 0x11, "SRP1"); + put(m, 0x12, "SRP2"); + put(m, 0x13, "SZP0"); + put(m, 0x14, "SZP1"); + put(m, 0x15, "SZP2"); + put(m, 0x16, "SZPS"); + put(m, 0x17, "SLOOP"); + put(m, 0x18, "RTG"); + put(m, 0x19, "RTHG"); + put(m, 0x1A, "SMD"); + put(m, 0x1B, "ELSE"); + put(m, 0x1C, "JMPR"); + put(m, 0x1D, "SCVTCI"); + put(m, 0x1E, "SSWCI"); + put(m, 0x1F, "SSW"); + put(m, 0x20, "DUP"); + put(m, 0x21, "POP"); + put(m, 0x22, "CLEAR"); + put(m, 0x23, "SWAP"); + put(m, 0x24, "DEPTH"); + put(m, 0x25, "CINDEX"); + put(m, 0x26, "MINDEX"); + put(m, 0x27, "ALIGNPTS"); + put(m, 0x29, "UTP"); + put(m, 0x2A, "LOOPCALL"); + put(m, 0x2B, "CALL"); + put(m, 0x2C, "FDEF"); + put(m, 0x2D, "ENDF"); + put(m, 0x2E, "MDAP[nr]"); + put(m, 0x2F, "MDAP[rnd]"); + put(m, 0x30, "IUP[y]"); + put(m, 0x31, "IUP[x]"); + put(m, 0x32, "SHP[rp2]"); + put(m, 0x33, "SHP[rp1]"); + put(m, 0x34, "SHC[rp2]"); + put(m, 0x35, "SHC[rp1]"); + put(m, 0x36, "SHZ[rp2]"); + put(m, 0x37, "SHZ[rp1]"); + put(m, 0x38, "SHPIX"); + put(m, 0x39, "IP"); + put(m, 0x3A, "MSIRP[nr]"); + put(m, 0x3B, "MSIRP[rp0]"); + put(m, 0x3C, "ALIGNRP"); + put(m, 0x3D, "RTDG"); + put(m, 0x3E, "MIAP[nr]"); + put(m, 0x3F, "MIAP[rnd]"); + put(m, 0x40, "NPUSHB"); + put(m, 0x41, "NPUSHW"); + put(m, 0x42, "WS"); + put(m, 0x43, "RS"); + put(m, 0x44, "WCVTP"); + put(m, 0x45, "RCVT"); + put(m, 0x46, "GC[cur]"); + put(m, 0x47, "GC[org]"); + put(m, 0x48, "SCFS"); + put(m, 0x49, "MD[grid]"); + put(m, 0x4A, "MD[org]"); + put(m, 0x4B, "MPPEM"); + put(m, 0x4C, "MPS"); + put(m, 0x4D, "FLIPON"); + put(m, 0x4E, "FLIPOFF"); + put(m, 0x4F, "DEBUG"); + put(m, 0x50, "LT"); + put(m, 0x51, "LTEQ"); + put(m, 0x52, "GT"); + put(m, 0x53, "GTEQ"); + put(m, 0x54, "EQ"); + put(m, 0x55, "NEQ"); + put(m, 0x56, "ODD"); + put(m, 0x57, "EVEN"); + put(m, 0x58, "IF"); + put(m, 0x59, "EIF"); + put(m, 0x5A, "AND"); + put(m, 0x5B, "OR"); + put(m, 0x5C, "NOT"); + put(m, 0x5D, "DELTAP1"); + put(m, 0x5E, "SDB"); + put(m, 0x5F, "SDS"); + put(m, 0x60, "ADD"); + put(m, 0x61, "SUB"); + put(m, 0x62, "DIV"); + put(m, 0x63, "MUL"); + put(m, 0x64, "ABS"); + put(m, 0x65, "NEG"); + put(m, 0x66, "FLOOR"); + put(m, 0x67, "CEILING"); + put(m, 0x70, "WCVTF"); + put(m, 0x71, "DELTAP2"); + put(m, 0x72, "DELTAP3"); + put(m, 0x73, "DELTAC1"); + put(m, 0x74, "DELTAC2"); + put(m, 0x75, "DELTAC3"); + put(m, 0x76, "SROUND"); + put(m, 0x77, "S45ROUND"); + put(m, 0x78, "JROT"); + put(m, 0x79, "JROF"); + put(m, 0x7A, "ROFF"); + put(m, 0x7C, "RUTG"); + put(m, 0x7D, "RDTG"); + put(m, 0x7E, "SANGW"); + put(m, 0x7F, "AA"); + put(m, 0x80, "FLIPPT"); + put(m, 0x81, "FLIPRGON"); + put(m, 0x82, "FLIPRGOFF"); + put(m, 0x85, "SCANCTRL"); + put(m, 0x86, "SDPVTL[||]"); + put(m, 0x87, "SDPVTL[+]"); + put(m, 0x88, "GETINFO"); + put(m, 0x89, "IDEF"); + put(m, 0x8A, "ROLL"); + put(m, 0x8B, "MAX"); + put(m, 0x8C, "MIN"); + put(m, 0x8D, "SCANTYPE"); + put(m, 0x8E, "INSTCTRL"); + for (int k = 0; k < 4; k++) + { + put(m, 0x68 + k, "ROUND[" + k + "]"); + put(m, 0x6C + k, "NROUND[" + k + "]"); + } + for (int k = 0; k < 8; k++) + { + put(m, 0xB0 + k, "PUSHB[" + (k + 1) + "]"); + put(m, 0xB8 + k, "PUSHW[" + (k + 1) + "]"); + } + for (int op = 0xC0; op <= 0xDF; op++) + { + put(m, op, "MDRP[" + Integer.toHexString(op & 0x1F) + "]"); + } + for (int op = 0xE0; op <= 0xFF; op++) + { + put(m, op, "MIRP[" + Integer.toHexString(op & 0x1F) + "]"); + } + return m; + } + + private static void put(String[] m, int opcode, String name) + { + m[opcode] = name; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/Fixed.java b/fontbox/src/main/java/org/apache/fontbox/ttf/Fixed.java new file mode 100644 index 00000000000..4f0d7623bd3 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/Fixed.java @@ -0,0 +1,210 @@ +/* + * 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.fontbox.ttf; + +/** + * Integer fixed-point math for the TrueType bytecode interpreter. + *

+ * The interpreter keeps coordinates in 26.6 fixed point (F26Dot6: 26 integer bits, 6 fractional bits, + * so 1 pixel == 64) and the projection/freedom vectors in 2.14 fixed point (F2Dot14). Mirroring + * FreeType's all-integer arithmetic - rather than mixing {@code float}/{@code double} - is what makes + * byte-exact comparison against a FreeType reference dump possible. + * + * @author Apache PDFBox + */ +final class Fixed +{ + /** One pixel in F26Dot6. */ + public static final int ONE = 64; + + /** Half a pixel in F26Dot6. */ + public static final int HALF = 32; + + /** 1.0 in F2Dot14. */ + public static final int ONE_F2DOT14 = 0x4000; + + private Fixed() + { + } + + /** + * Converts an integer to F26Dot6. + * + * @param value an integer pixel value + * @return the value in F26Dot6 + */ + public static int fromInt(int value) + { + return value << 6; + } + + /** + * Converts an F26Dot6 value back to an integer, rounding to nearest. + * + * @param value an F26Dot6 value + * @return the nearest integer + */ + public static int toInt(int value) + { + return (value + HALF) >> 6; + } + + /** + * Rounds an F26Dot6 value down to the pixel grid (towards negative infinity). + * + * @param value an F26Dot6 value + * @return the floored value, still in F26Dot6 + */ + public static int floor(int value) + { + return value & ~63; + } + + /** + * Rounds an F26Dot6 value up to the pixel grid. + * + * @param value an F26Dot6 value + * @return the ceiling value, still in F26Dot6 + */ + public static int ceil(int value) + { + return (value + 63) & ~63; + } + + /** + * Rounds an F26Dot6 value to the nearest pixel grid line (round-to-grid). + * + * @param value an F26Dot6 value + * @return the rounded value, still in F26Dot6 + */ + public static int round(int value) + { + return floor(value + HALF); + } + + /** + * Computes {@code round(a * b / c)} in 64-bit with correct sign handling, matching FreeType's + * {@code FT_MulDiv}. Used to build the F26Dot6 and F2Dot14 operators below. + * + * @param a first operand + * @param b second operand + * @param c divisor + * @return the rounded result + */ + public static int mulDiv(int a, int b, int c) + { + long la = a; + long lb = b; + long lc = c; + int sign = 1; + if (la < 0) + { + la = -la; + sign = -sign; + } + if (lb < 0) + { + lb = -lb; + sign = -sign; + } + if (lc < 0) + { + lc = -lc; + sign = -sign; + } + long result = lc != 0 ? (la * lb + lc / 2) / lc : 0x7FFFFFFFL; + return (int) (sign * result); + } + + /** + * Multiplies two F26Dot6 values, returning an F26Dot6 result (the TrueType {@code MUL} operator). + * + * @param a first F26Dot6 operand + * @param b second F26Dot6 operand + * @return {@code a * b} in F26Dot6 + */ + public static int mul(int a, int b) + { + return mulDiv(a, b, ONE); + } + + /** + * Divides two F26Dot6 values, returning an F26Dot6 result (the TrueType {@code DIV} operator). + * Division by zero yields zero. Unlike {@link #mul(int, int)} this truncates toward zero + * rather than rounding, matching FreeType's {@code DIV} opcode (which uses {@code FT_MulDiv_No_Round}). + * + * @param a F26Dot6 dividend + * @param b F26Dot6 divisor + * @return {@code a / b} in F26Dot6, or 0 if {@code b == 0} + */ + public static int div(int a, int b) + { + if (b == 0) + { + return 0; + } + long la = a; + long lb = b; + int sign = 1; + if (la < 0) + { + la = -la; + sign = -sign; + } + if (lb < 0) + { + lb = -lb; + sign = -sign; + } + return (int) (sign * (la * ONE / lb)); + } + + /** + * Multiplies an F26Dot6 value by an F2Dot14 value, returning F26Dot6. This is the building block + * for projecting a distance onto the projection/freedom vector. + * + * @param a an F26Dot6 value + * @param b an F2Dot14 value + * @return {@code a * b} in F26Dot6 + */ + public static int mul14(int a, int b) + { + return mulDiv(a, b, ONE_F2DOT14); + } + + /** + * Scales a coordinate from font units to F26Dot6 device pixels at the given ppem: + * {@code round(funits * ppem * 64 / unitsPerEm)}. Note this is not a flat {@code * 64} - + * that would only be correct when {@code unitsPerEm == ppem}. Both control values and glyph point + * coordinates are scaled with this. + * + * @param funits a value in font units + * @param ppem the active pixels-per-em + * @param unitsPerEm the font's unitsPerEm (from the head table) + * @return the value in F26Dot6 device pixels + */ + public static int scale(int funits, int ppem, int unitsPerEm) + { + if (unitsPerEm == 0) + { + return 0; + } + long numerator = (long) funits * ppem * ONE; + long rounded = numerator >= 0 ? numerator + unitsPerEm / 2 : numerator - unitsPerEm / 2; + return (int) (rounded / unitsPerEm); + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/FontProgramTable.java b/fontbox/src/main/java/org/apache/fontbox/ttf/FontProgramTable.java new file mode 100644 index 00000000000..2e3e3d7d349 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/FontProgramTable.java @@ -0,0 +1,64 @@ +/* + * 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.fontbox.ttf; + +import java.io.IOException; + +/** + * The 'fpgm' (Font Program) table. It holds TrueType hinting bytecode - typically a set of function + * definitions (FDEF) - that is executed once when the font is first used, before any glyph is hinted. + * The bytecode is stored here as raw bytes and run by the interpreter. + * + * @author Apache PDFBox + */ +public class FontProgramTable extends TTFTable +{ + /** + * A tag that identifies this table type. + */ + public static final String TAG = "fpgm"; + + private byte[] program; + + FontProgramTable() + { + } + + /** + * This will read the required data from the stream. + * + * @param ttf The font that is being read. + * @param data The stream to read the data from. + * @throws IOException If there is an error reading the data. + */ + @Override + void read(TrueTypeFont ttf, TTFDataStream data) throws IOException + { + program = data.read((int) getLength()); + initialized = true; + } + + /** + * Returns the raw font program bytecode. + * + * @return the bytecode of the font program + */ + public byte[] getProgram() + { + return program; + } +} \ No newline at end of file diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/FunctionDef.java b/fontbox/src/main/java/org/apache/fontbox/ttf/FunctionDef.java new file mode 100644 index 00000000000..11d44023fb9 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/FunctionDef.java @@ -0,0 +1,52 @@ +/* + * 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.fontbox.ttf; + +/** + * A function defined by an {@code FDEF} instruction: the bytecode program it lives in (normally the + * {@code fpgm}) and the offset of its first body instruction, just past the {@code FDEF}. {@code CALL} + * and {@code LOOPCALL} run the body from this offset until the matching {@code ENDF}. + * + * @author Apache PDFBox + */ +class FunctionDef +{ + private final byte[] program; + private final int entryPoint; + + /** + * @param program the bytecode the function body lives in + * @param entryPoint the offset of the first instruction after {@code FDEF} + */ + public FunctionDef(byte[] program, int entryPoint) + { + this.program = program; + this.entryPoint = entryPoint; + } + + /** @return the bytecode the function body lives in */ + public byte[] getProgram() + { + return program; + } + + /** @return the offset of the first body instruction */ + public int getEntryPoint() + { + return entryPoint; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/GaspTable.java b/fontbox/src/main/java/org/apache/fontbox/ttf/GaspTable.java new file mode 100644 index 00000000000..baf354e97b8 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/GaspTable.java @@ -0,0 +1,147 @@ +/* + * 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.fontbox.ttf; + +import java.io.IOException; + +/** + * The 'gasp' (Grid-fitting And Scan-conversion Procedure) table. It maps ppem ranges to flags that + * advise whether grid-fitting (hinting) and/or grayscale anti-aliasing should be applied at that size. + * The ranges are sorted by ascending maximum ppem; the last range always ends at 0xFFFF. + * + * @author Apache PDFBox + */ +public class GaspTable extends TTFTable +{ + /** + * A tag that identifies this table type. + */ + public static final String TAG = "gasp"; + + /** + * Use grid-fitting (i.e. execute the hinting bytecode) at this size. + */ + public static final int GASP_GRIDFIT = 0x0001; + + /** + * Use grayscale (anti-aliased) rendering at this size. + */ + public static final int GASP_DOGRAY = 0x0002; + + /** + * Use grid-fitting with ClearType symmetric smoothing (gasp version 1). + */ + public static final int GASP_SYMMETRIC_GRIDFIT = 0x0004; + + /** + * Use smoothing along multiple axes with ClearType (gasp version 1). + */ + public static final int GASP_SYMMETRIC_SMOOTHING = 0x0008; + + private int version; + private int[] rangeMaxPPEM; + private int[] rangeFlags; + + GaspTable() + { + } + + /** + * This will read the required data from the stream. + * + * @param ttf The font that is being read. + * @param data The stream to read the data from. + * @throws IOException If there is an error reading the data. + */ + @Override + void read(TrueTypeFont ttf, TTFDataStream data) throws IOException + { + version = data.readUnsignedShort(); + int numRanges = data.readUnsignedShort(); + rangeMaxPPEM = new int[numRanges]; + rangeFlags = new int[numRanges]; + for (int i = 0; i < numRanges; i++) + { + rangeMaxPPEM[i] = data.readUnsignedShort(); + rangeFlags[i] = data.readUnsignedShort(); + } + initialized = true; + } + + /** + * @return the table version (0 or 1) + */ + public int getVersion() + { + return version; + } + + /** + * Returns the upper ppem bound of each range, in ascending order. The last entry is 0xFFFF. + * + * @return the per-range maximum ppem values + */ + public int[] getRangeMaxPPEM() + { + return rangeMaxPPEM; + } + + /** + * Returns the flags for each range, parallel to {@link #getRangeMaxPPEM()}. + * + * @return the per-range flags + */ + public int[] getRangeFlags() + { + return rangeFlags; + } + + /** + * Returns the behavior flags that apply at the given ppem - those of the first range whose + * maximum ppem is greater than or equal to the requested ppem. + * + * @param ppem the pixels-per-em to look up + * @return the flags for that ppem, or 0 if the table has no ranges + */ + public int getFlags(int ppem) + { + if (rangeMaxPPEM == null) + { + return 0; + } + for (int i = 0; i < rangeMaxPPEM.length; i++) + { + if (ppem <= rangeMaxPPEM[i]) + { + return rangeFlags[i]; + } + } + // beyond the last range (should not happen as the last bound is 0xFFFF) + return rangeFlags.length > 0 ? rangeFlags[rangeFlags.length - 1] : 0; + } + + /** + * Convenience test for whether grid-fitting (hinting) is advised at the given ppem. + * + * @param ppem the pixels-per-em to look up + * @return true if {@link #GASP_GRIDFIT} is set for that ppem + */ + public boolean isGridFit(int ppem) + { + return (getFlags(ppem) & GASP_GRIDFIT) != 0; + } +} \ No newline at end of file diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphHinter.java b/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphHinter.java new file mode 100644 index 00000000000..82c8c6dc558 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphHinter.java @@ -0,0 +1,518 @@ +/* + * 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.fontbox.ttf; + +import java.awt.geom.GeneralPath; +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Applies TrueType bytecode hinting (grid-fitting) to a font's glyphs, producing grid-fitted paths. + *

+ * One hinter is created per {@link TrueTypeFont}. It lazily builds a {@link TrueTypeInterpreter} from + * the font's {@code maxp}/{@code head}/{@code cvt}/{@code fpgm}/{@code prep} tables, runs the font + * program once, and re-runs the control value program whenever the ppem changes. For each glyph it + * scales the outline into the pixel grid (appending the phantom points), runs the glyph's instructions + * and scales the grid-fitted result back into font units, so the rest of the rendering pipeline - which + * scales font units to device pixels at exactly this ppem - reproduces the grid-fitting. + *

+ * Hinting is best-effort: anything malformed, unsupported, or not applicable (a composite glyph, a + * glyph with no instructions, a ppem the {@code gasp} table excludes) falls back to {@code null}, and + * the caller renders the raw outline. One bad glyph never disables hinting for the rest of the font. + * Whether to hint at all is the caller's decision; this class always grid-fits when asked. + *

+ * The interpreter carries a great deal of mutable state - the storage area, the twilight zone, the + * post-{@code prep} template, the active ppem - so every entry point here is {@code synchronized} and + * one font hints one glyph at a time. That is correct but it does serialize: a font substituted from + * the system is held in a process-wide cache, so several rendering threads can share one instance and + * queue on this monitor. Embedded fonts are per-document and unaffected. If it ever measures as a + * bottleneck the answer is a per-thread or pooled interpreter, not a weaker lock; until then the simple + * thing is the right thing. {@code HintingConcurrencyTest} pins the current behavior. + * + * @author Apache PDFBox + */ +class GlyphHinter +{ + private static final Logger LOG = LogManager.getLogger(GlyphHinter.class); + + private final TrueTypeFont font; + + private boolean initialized; + private boolean available; + private boolean warned; + private TrueTypeInterpreter interpreter; + private GaspTable gasp; + private int unitsPerEm; + private int currentPpem = -1; + + GlyphHinter(TrueTypeFont font) + { + this.font = font; + } + + private synchronized void initialize() throws IOException + { + if (initialized) + { + return; + } + initialized = true; + available = false; + + MaximumProfileTable maxp = font.getMaximumProfile(); + FontProgramTable fpgm = font.getFontProgram(); + ControlValueProgramTable prep = font.getControlValueProgram(); + ControlValueTable cvt = font.getControlValues(); + + // hinting is only meaningful if the font carries a bytecode program + if (maxp == null || (fpgm == null && prep == null)) + { + return; + } + + unitsPerEm = font.getUnitsPerEm(); + gasp = font.getGasp(); + + interpreter = new TrueTypeInterpreter(maxp.getMaxStackElements(), maxp.getMaxStorage(), + maxp.getMaxTwilightPoints(), unitsPerEm); + interpreter.setFontProgram(fpgm != null ? fpgm.getProgram() : null); + interpreter.setControlValueProgram(prep != null ? prep.getProgram() : null); + interpreter.setControlValues(cvt != null ? cvt.getValues() : null); + interpreter.prepareFontProgram(); + available = true; + } + + /** + * Returns the grid-fitted path of the glyph at the given ppem, or {@code null} if hinting does not + * apply and the caller should render the raw outline. + * + * @param gid the glyph id + * @param ppem the pixels-per-em to grid-fit to + * @return the hinted path in font units, or null + */ + synchronized GeneralPath getPath(int gid, int ppem) + { + Hinted hinted = hint(gid, ppem); + if (hinted == null) + { + return null; + } + // scale the grid-fitted coordinates back into font units (drop the phantom points) + int[] hintedX = new int[hinted.pointCount]; + int[] hintedY = new int[hinted.pointCount]; + int[] curX = hinted.zone.getCurrentX(); + int[] curY = hinted.zone.getCurrentY(); + for (int i = 0; i < hinted.pointCount; i++) + { + hintedX[i] = toFontUnits(curX[i], ppem); + hintedY[i] = toFontUnits(curY[i], ppem); + } + return new GlyphRenderer(hinted.gd, hintedX, hintedY).getPath(); + } + + /** + * Returns the grid-fitted glyph points in F26Dot6 device coordinates (the raw interpreter output, + * before scaling back to font units, and excluding the phantom points), or {@code null} if hinting + * does not apply. This is the form compared against a FreeType reference dump by the golden tests. + * + * @param gid the glyph id + * @param ppem the pixels-per-em to grid-fit to + * @return a {@code {x[], y[]}} pair in F26Dot6, or null + */ + synchronized int[][] getHintedPointsF26Dot6(int gid, int ppem) + { + Hinted hinted = hint(gid, ppem); + if (hinted == null) + { + return null; + } + int[] x = new int[hinted.pointCount]; + int[] y = new int[hinted.pointCount]; + System.arraycopy(hinted.zone.getCurrentX(), 0, x, 0, hinted.pointCount); + System.arraycopy(hinted.zone.getCurrentY(), 0, y, 0, hinted.pointCount); + return new int[][] { x, y }; + } + + /** + * Runs the control value program untraced, then grid-fits the glyph with an execution tracer + * attached, so the per-instruction trace can be diffed against FreeType's {@code ttinterp} trace. + * For development/debugging only. + * + * @param gid the glyph id + * @param ppem the pixels-per-em + * @param out where to write the trace + * @param tracePoint a glyph point index to log per instruction, or -1 + * @throws IOException if the font could not be read + */ + synchronized void traceGlyph(int gid, int ppem, java.io.PrintStream out, int tracePoint) + throws IOException + { + initialize(); + if (!available) + { + return; + } + setActivePpem(ppem); + interpreter.setTracer(new ExecutionTracer(out, tracePoint)); + try + { + hint(gid, ppem, 0); + } + finally + { + interpreter.setTracer(null); + } + } + + /** Maximum composite nesting depth, to bound recursion on pathological fonts. */ + private static final int MAX_COMPONENT_DEPTH = 8; + + /** Runs all gating, then grid-fits the glyph, returning the executed zone or null on fallback. */ + private Hinted hint(int gid, int ppem) + { + if (ppem <= 0) + { + return null; + } + try + { + initialize(); + if (!available) + { + return null; + } + // gasp gate: if a gasp table is present and does not request grid-fitting here, skip + if (gasp != null && !gasp.isGridFit(ppem)) + { + return null; + } + setActivePpem(ppem); + return hint(gid, ppem, 0); + } + catch (IOException | RuntimeException e) + { + logFailure(gid, ppem, e); + return null; + } + } + + /** + * Reports a glyph that could not be hinted. Only the first failure in a font is a warning carrying + * the stack trace; the rest go to debug. Hinting is attempted per {@code (glyph, ppem)} pair, so a + * font whose bytecode never runs - a malformed program, or one using something unimplemented - would + * otherwise emit thousands of identical stack traces for a page of CJK text. + */ + private void logFailure(int gid, int ppem, Exception e) + { + if (warned) + { + LOG.debug("hinting failed for glyph {} at {}ppem, using raw outline", gid, ppem, e); + return; + } + warned = true; + LOG.warn("hinting failed for glyph {} at {}ppem in font {}, using raw outline; further " + + "failures in this font are logged at debug level", gid, ppem, fontName(), e); + } + + /** The font's PostScript name for the warning above, best-effort - we are already handling a fault. */ + private String fontName() + { + try + { + return font.getName(); + } + catch (IOException e) + { + return ""; + } + } + + /** + * Re-runs the control value program if the ppem changed. The guard is load-bearing, not just an + * optimization: {@code setPpem} clears the storage area and twilight zone before running + * {@code prep}, so re-running it per glyph would wipe the values {@code prep} seeded for the glyph + * programs to read. + */ + private void setActivePpem(int ppem) throws IOException + { + if (ppem != currentPpem) + { + interpreter.setPpem(ppem, ppem); + currentPpem = ppem; + } + } + + /** Grid-fits one glyph (simple or composite), recursing into components. */ + private Hinted hint(int gid, int ppem, int depth) throws IOException + { + if (depth > MAX_COMPONENT_DEPTH) + { + return null; + } + GlyphData glyph = font.getGlyph().getGlyph(gid); + if (glyph == null) + { + return null; + } + GlyphDescription gd = glyph.getDescription(); + if (!(gd instanceof GlyfDescript)) + { + return null; + } + if (gd.isComposite()) + { + gd.resolve(); + if (gd.getPointCount() == 0) + { + return null; + } + return hintComposite(glyph, (GlyfCompositeDescript) gd, gid, ppem, depth); + } + if (gd.getContourCount() == 0 || gd.getPointCount() == 0) + { + // empty glyph (e.g. space, newline): nothing to hint + return null; + } + int[] instructions = ((GlyfDescript) gd).getInstructions(); + if (instructions == null || instructions.length == 0) + { + return null; + } + int pointCount = gd.getPointCount(); + Zone zone = buildZone(glyph, gd, gid, ppem, pointCount, gd.getContourCount()); + runProgram(zone, instructions, ppem, false); + return new Hinted(gd, zone, pointCount); + } + + /** + * Grid-fits a composite glyph the way FreeType does: each component is hinted on its own, then + * transformed and offset into the composite's coordinate space, the phantom points appended, and + * finally the composite's own instructions (if any) run over the assembled outline. + */ + private Hinted hintComposite(GlyphData glyph, GlyfCompositeDescript composite, int gid, int ppem, + int depth) throws IOException + { + int pointCount = composite.getPointCount(); + int contourCount = composite.getContourCount(); + Zone zone = new Zone(pointCount + 4, contourCount); + + for (GlyfCompositeComp comp : composite.getComponents()) + { + assembleComponent(comp, ppem, depth, zone); + } + int[] ends = zone.getContourEnds(); + for (int c = 0; c < contourCount; c++) + { + ends[c] = composite.getEndPtOfContours(c); + } + appendPhantomPoints(glyph, gid, ppem, pointCount, zone); + + int[] instructions = composite.getInstructions(); + if (instructions != null && instructions.length > 0) + { + runProgram(zone, instructions, ppem, true); + } + return new Hinted(composite, zone, pointCount); + } + + /** + * Hints one component glyph and writes its transformed/offset points into the composite's zone + * arrays. The component's grid-fitted outline goes to the current arrays and its scaled-but-unhinted + * outline to the original arrays, so the composite's instructions can measure original distances. + */ + private void assembleComponent(GlyfCompositeComp comp, int ppem, int depth, Zone zone) + throws IOException + { + int componentGid = comp.getGlyphIndex(); + int first = comp.getFirstIndex(); + + GlyphData componentGlyph = font.getGlyph().getGlyph(componentGid); + GlyphDescription cgd = componentGlyph != null ? componentGlyph.getDescription() : null; + if (cgd == null) + { + return; + } + if (cgd.isComposite()) + { + cgd.resolve(); + } + int count = cgd.getPointCount(); + boolean[] onCurve = zone.getOnCurve(); + + // scaled-but-unhinted component points (used as a fallback) and the unscaled font-unit ones + int[] cOrgX = new int[count]; + int[] cOrgY = new int[count]; + int[] cUnsX = new int[count]; + int[] cUnsY = new int[count]; + for (int k = 0; k < count; k++) + { + cUnsX[k] = cgd.getXCoordinate(k); + cUnsY[k] = cgd.getYCoordinate(k); + cOrgX[k] = Fixed.scale(cUnsX[k], ppem, unitsPerEm); + cOrgY[k] = Fixed.scale(cUnsY[k], ppem, unitsPerEm); + onCurve[first + k] = (cgd.getFlags(k) & GlyfDescript.ON_CURVE) != 0; + } + + // grid-fitted component points (its own instructions executed); fall back to unhinted + int[] cCurX = cOrgX; + int[] cCurY = cOrgY; + Hinted hintedComponent = hint(componentGid, ppem, depth + 1); + if (hintedComponent != null && hintedComponent.pointCount == count) + { + cCurX = hintedComponent.zone.getCurrentX(); + cCurY = hintedComponent.zone.getCurrentY(); + } + + // device-space offset (FreeType does not grid-round the component offset here, even when + // ROUND_XY_TO_GRID is set, so neither do we); the unscaled offset stays in font units + int offsetX = Fixed.scale(comp.getXTranslate(), ppem, unitsPerEm); + int offsetY = Fixed.scale(comp.getYTranslate(), ppem, unitsPerEm); + int unsOffsetX = comp.getXTranslate(); + int unsOffsetY = comp.getYTranslate(); + + int[] curX = zone.getCurrentX(); + int[] curY = zone.getCurrentY(); + int[] orgX = zone.getOriginalX(); + int[] orgY = zone.getOriginalY(); + int[] unsX = zone.getUnscaledX(); + int[] unsY = zone.getUnscaledY(); + for (int k = 0; k < count; k++) + { + curX[first + k] = comp.scaleX(cCurX[k], cCurY[k]) + offsetX; + curY[first + k] = comp.scaleY(cCurX[k], cCurY[k]) + offsetY; + // FreeType bakes each hinted component into the composite and copies cur -> org before + // running the composite program, so the original equals the assembled hinted position + // (a SHC/MDRP in the composite then measures zero movement for an unmoved component point) + orgX[first + k] = curX[first + k]; + orgY[first + k] = curY[first + k]; + unsX[first + k] = comp.scaleX(cUnsX[k], cUnsY[k]) + unsOffsetX; + unsY[first + k] = comp.scaleY(cUnsX[k], cUnsY[k]) + unsOffsetY; + } + } + + /** Clones the saved post-prep state, resets it for the glyph, and runs the program over the zone. */ + private void runProgram(Zone zone, int[] instructions, int ppem, boolean composite) + { + GraphicsState gs = interpreter.getSavedState().copy(); + gs.resetForGlyph(); + ExecutionContext ctx = interpreter.newContext(gs); + ctx.setPpem(ppem); + ctx.setGlyphZone(zone); + // v40 grayscale "backward compatibility" applies to the glyph program only, never fpgm/prep, + // which build control values via twilight-zone x/y moves that must not be suppressed + ctx.setBackwardCompatibility(true); + ctx.setComposite(composite); + interpreter.run(ctx, new BytecodeStream(toByteArray(instructions))); + } + + /** The result of grid-fitting one glyph: its description, the executed zone, and its point count + * (without the appended phantom points). */ + private static final class Hinted + { + private final GlyphDescription gd; + private final Zone zone; + private final int pointCount; + + Hinted(GlyphDescription gd, Zone zone, int pointCount) + { + this.gd = gd; + this.zone = zone; + this.pointCount = pointCount; + } + } + + private Zone buildZone(GlyphData glyph, GlyphDescription gd, int gid, int ppem, int pointCount, + int contourCount) throws IOException + { + // four phantom points are appended after the glyph's own points + Zone zone = new Zone(pointCount + 4, contourCount); + int[] curX = zone.getCurrentX(); + int[] curY = zone.getCurrentY(); + int[] orgX = zone.getOriginalX(); + int[] orgY = zone.getOriginalY(); + int[] unsX = zone.getUnscaledX(); + int[] unsY = zone.getUnscaledY(); + boolean[] onCurve = zone.getOnCurve(); + for (int i = 0; i < pointCount; i++) + { + int fx = gd.getXCoordinate(i); + int fy = gd.getYCoordinate(i); + unsX[i] = fx; + unsY[i] = fy; + int x = Fixed.scale(fx, ppem, unitsPerEm); + int y = Fixed.scale(fy, ppem, unitsPerEm); + orgX[i] = x; + orgY[i] = y; + curX[i] = x; + curY[i] = y; + onCurve[i] = (gd.getFlags(i) & GlyfDescript.ON_CURVE) != 0; + } + int[] ends = zone.getContourEnds(); + for (int c = 0; c < contourCount; c++) + { + ends[c] = gd.getEndPtOfContours(c); + } + appendPhantomPoints(glyph, gid, ppem, pointCount, zone); + return zone; + } + + private void appendPhantomPoints(GlyphData glyph, int gid, int ppem, int pointCount, Zone zone) + throws IOException + { + HorizontalMetricsTable hmtx = font.getHorizontalMetrics(); + int advanceWidth = hmtx != null ? hmtx.getAdvanceWidth(gid) : unitsPerEm; + int leftSideBearing = hmtx != null ? hmtx.getLeftSideBearing(gid) : 0; + int originX = glyph.getXMinimum() - leftSideBearing; + int yMax = glyph.getYMaximum(); + + // pp1 = origin, pp2 = origin + advance (horizontal); pp3/pp4 are the vertical pair + int[] px = { originX, originX + advanceWidth, 0, 0 }; + int[] py = { 0, 0, yMax, yMax - unitsPerEm }; + for (int i = 0; i < 4; i++) + { + int index = pointCount + i; + zone.getUnscaledX()[index] = px[i]; + zone.getUnscaledY()[index] = py[i]; + zone.getOriginalX()[index] = Fixed.scale(px[i], ppem, unitsPerEm); + zone.getOriginalY()[index] = Fixed.scale(py[i], ppem, unitsPerEm); + // FreeType rounds the phantom points to the grid before running the glyph program + zone.getCurrentX()[index] = Fixed.round(zone.getOriginalX()[index]); + zone.getCurrentY()[index] = Fixed.round(zone.getOriginalY()[index]); + } + } + + /** Scales an F26Dot6 device coordinate back to font units. */ + private int toFontUnits(int f26dot6, int ppem) + { + long numerator = (long) f26dot6 * unitsPerEm; + long denominator = (long) ppem * Fixed.ONE; + long half = denominator / 2; + return (int) ((numerator >= 0 ? numerator + half : numerator - half) / denominator); + } + + private static byte[] toByteArray(int[] instructions) + { + byte[] bytes = new byte[instructions.length]; + for (int i = 0; i < instructions.length; i++) + { + bytes[i] = (byte) instructions[i]; + } + return bytes; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphRenderer.java b/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphRenderer.java index f54456f0276..0191cb5141f 100644 --- a/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphRenderer.java +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/GlyphRenderer.java @@ -41,10 +41,28 @@ class GlyphRenderer private static final Logger LOG = LogManager.getLogger(GlyphRenderer.class); private final GlyphDescription glyphDescription; + private final int[] hintedX; + private final int[] hintedY; GlyphRenderer(GlyphDescription glyphDescription) + { + this(glyphDescription, null, null); + } + + /** + * Creates a renderer that builds the path from grid-fitted (hinted) coordinates instead of the + * glyph's raw coordinates. The arrays are in font units (the hinting having been applied and + * scaled back), and must be parallel to the glyph's points. + * + * @param glyphDescription the glyph description + * @param hintedX the hinted x coordinates in font units, or null for unhinted + * @param hintedY the hinted y coordinates in font units, or null for unhinted + */ + GlyphRenderer(GlyphDescription glyphDescription, int[] hintedX, int[] hintedY) { this.glyphDescription = glyphDescription; + this.hintedX = hintedX; + this.hintedY = hintedY; } /** @@ -77,8 +95,9 @@ private Point[] describe(GlyphDescription gd) endPtIndex++; endPtOfContourIndex = -1; } - points[i] = new Point(gd.getXCoordinate(i), gd.getYCoordinate(i), - (gd.getFlags(i) & GlyfDescript.ON_CURVE) != 0, endPt); + int x = hintedX != null ? hintedX[i] : gd.getXCoordinate(i); + int y = hintedY != null ? hintedY[i] : gd.getYCoordinate(i); + points[i] = new Point(x, y, (gd.getFlags(i) & GlyfDescript.ON_CURVE) != 0, endPt); } return points; } diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/GraphicsState.java b/fontbox/src/main/java/org/apache/fontbox/ttf/GraphicsState.java new file mode 100644 index 00000000000..708afad19d4 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/GraphicsState.java @@ -0,0 +1,530 @@ +/* + * 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.fontbox.ttf; + +/** + * The TrueType interpreter graphics state: projection/freedom vectors, reference and zone pointers, + * round state, and the various cut-ins and distances. Two operations carry the correctness burden: + *

+ * + * @author Apache PDFBox + */ +class GraphicsState +{ + /** Round to grid - the default round state. */ + public static final int ROUND_TO_GRID = 0; + /** Round to half grid. */ + public static final int ROUND_TO_HALF_GRID = 1; + /** Round to double grid. */ + public static final int ROUND_TO_DOUBLE_GRID = 2; + /** Round down to grid. */ + public static final int ROUND_DOWN_TO_GRID = 3; + /** Round up to grid. */ + public static final int ROUND_UP_TO_GRID = 4; + /** Rounding off. */ + public static final int ROUND_OFF = 5; + /** Super round (set by SROUND). */ + public static final int ROUND_SUPER = 6; + /** Super round 45 degrees (set by S45ROUND). */ + public static final int ROUND_SUPER_45 = 7; + + private UnitVector projectionVector; + private UnitVector freedomVector; + private UnitVector dualProjectionVector; + + private int rp0; + private int rp1; + private int rp2; + + private int zp0; + private int zp1; + private int zp2; + + private int loop; + private int roundState; + // derived rounding parameters (all F26Dot6), configured from the round state + private int roundPeriod; + private int roundPhase; + private int roundThreshold; + private boolean roundOff; + + private int minimumDistance; + private int controlValueCutIn; + private int singleWidthCutIn; + private int singleWidthValue; + private int deltaBase; + private int deltaShift; + private boolean autoFlip; + private int scanControl; + private int scanType; + private int instructControl; + + /** + * Creates a graphics state with the spec-mandated default values. This is the state before the + * font's {@code fpgm}/{@code prep} programs run. + */ + public GraphicsState() + { + projectionVector = UnitVector.xAxis(); + freedomVector = UnitVector.xAxis(); + dualProjectionVector = UnitVector.xAxis(); + rp0 = 0; + rp1 = 0; + rp2 = 0; + zp0 = 1; + zp1 = 1; + zp2 = 1; + loop = 1; + setRoundState(ROUND_TO_GRID); + minimumDistance = Fixed.ONE; // 1 pixel + controlValueCutIn = 17 * Fixed.ONE / 16; // 17/16 pixel = 68 + singleWidthCutIn = 0; + singleWidthValue = 0; + deltaBase = 9; + deltaShift = 3; + autoFlip = true; + scanControl = 0; + scanType = 0; + instructControl = 0; + } + + private GraphicsState(GraphicsState src) + { + projectionVector = src.projectionVector.copy(); + freedomVector = src.freedomVector.copy(); + dualProjectionVector = src.dualProjectionVector.copy(); + rp0 = src.rp0; + rp1 = src.rp1; + rp2 = src.rp2; + zp0 = src.zp0; + zp1 = src.zp1; + zp2 = src.zp2; + loop = src.loop; + roundState = src.roundState; + roundPeriod = src.roundPeriod; + roundPhase = src.roundPhase; + roundThreshold = src.roundThreshold; + roundOff = src.roundOff; + minimumDistance = src.minimumDistance; + controlValueCutIn = src.controlValueCutIn; + singleWidthCutIn = src.singleWidthCutIn; + singleWidthValue = src.singleWidthValue; + deltaBase = src.deltaBase; + deltaShift = src.deltaShift; + autoFlip = src.autoFlip; + scanControl = src.scanControl; + scanType = src.scanType; + instructControl = src.instructControl; + } + + /** + * Returns an independent deep copy, with the {@link UnitVector} fields cloned rather than shared. + * + * @return a deep copy of this graphics state + */ + public GraphicsState copy() + { + return new GraphicsState(this); + } + + /** + * Resets the per-glyph graphics state fields to their defaults, as the TrueType spec requires at + * the start of each glyph's instruction stream. The projection, freedom and dual-projection + * vectors return to the x axis; the reference points reset to 0; the zone pointers reset to the + * glyph zone (1); the loop counter resets to 1. Fields configured by {@code prep} (round state, + * cut-ins, minimum distance, delta base/shift, single width, auto-flip, scan control) are left + * untouched. + */ + public void resetForGlyph() + { + projectionVector.set(Fixed.ONE_F2DOT14, 0); + freedomVector.set(Fixed.ONE_F2DOT14, 0); + dualProjectionVector.set(Fixed.ONE_F2DOT14, 0); + rp0 = 0; + rp1 = 0; + rp2 = 0; + zp0 = 1; + zp1 = 1; + zp2 = 1; + loop = 1; + } + + /** @return the projection vector */ + public UnitVector getProjectionVector() + { + return projectionVector; + } + + /** @return the freedom vector */ + public UnitVector getFreedomVector() + { + return freedomVector; + } + + /** @return the dual projection vector */ + public UnitVector getDualProjectionVector() + { + return dualProjectionVector; + } + + /** @return reference point 0 */ + public int getRp0() + { + return rp0; + } + + /** @param value reference point 0 */ + public void setRp0(int value) + { + rp0 = value; + } + + /** @return reference point 1 */ + public int getRp1() + { + return rp1; + } + + /** @param value reference point 1 */ + public void setRp1(int value) + { + rp1 = value; + } + + /** @return reference point 2 */ + public int getRp2() + { + return rp2; + } + + /** @param value reference point 2 */ + public void setRp2(int value) + { + rp2 = value; + } + + /** @return zone pointer 0 */ + public int getZp0() + { + return zp0; + } + + /** @param value zone pointer 0 */ + public void setZp0(int value) + { + zp0 = value; + } + + /** @return zone pointer 1 */ + public int getZp1() + { + return zp1; + } + + /** @param value zone pointer 1 */ + public void setZp1(int value) + { + zp1 = value; + } + + /** @return zone pointer 2 */ + public int getZp2() + { + return zp2; + } + + /** @param value zone pointer 2 */ + public void setZp2(int value) + { + zp2 = value; + } + + /** @return the loop counter */ + public int getLoop() + { + return loop; + } + + /** @param value the loop counter */ + public void setLoop(int value) + { + loop = value; + } + + /** @return the round state */ + public int getRoundState() + { + return roundState; + } + + /** + * Sets the round state and derives the period/phase/threshold the {@link #round(int)} engine uses. + * The simple states are expressed as special cases of the super-round parameters. {@code SROUND} + * and {@code S45ROUND} call {@link #setSuperRound(int, int)} instead. + * + * @param value one of the {@code ROUND_*} constants + */ + public void setRoundState(int value) + { + roundState = value; + roundOff = false; + switch (value) + { + case ROUND_TO_GRID: + roundPeriod = Fixed.ONE; + roundPhase = 0; + roundThreshold = Fixed.HALF; + break; + case ROUND_TO_HALF_GRID: + roundPeriod = Fixed.ONE; + roundPhase = Fixed.HALF; + roundThreshold = Fixed.HALF; + break; + case ROUND_TO_DOUBLE_GRID: + roundPeriod = Fixed.HALF; + roundPhase = 0; + roundThreshold = Fixed.HALF / 2; + break; + case ROUND_DOWN_TO_GRID: + roundPeriod = Fixed.ONE; + roundPhase = 0; + roundThreshold = 0; + break; + case ROUND_UP_TO_GRID: + roundPeriod = Fixed.ONE; + roundPhase = 0; + roundThreshold = Fixed.ONE - 1; + break; + case ROUND_OFF: + roundOff = true; + break; + default: + // ROUND_SUPER / ROUND_SUPER_45 are configured by setSuperRound + break; + } + } + + /** + * Configures super-round parameters for {@code SROUND}/{@code S45ROUND} from the selector byte, + * per the TrueType specification. + * + * @param gridPeriod the base grid period in F26Dot6 (one pixel for SROUND; the diagonal for + * S45ROUND) + * @param selector the operand byte controlling period, phase and threshold + */ + public void setSuperRound(int gridPeriod, int selector) + { + switch (selector & 0xC0) + { + case 0x00: + roundPeriod = gridPeriod / 2; + break; + case 0x80: + roundPeriod = gridPeriod * 2; + break; + default: + roundPeriod = gridPeriod; + break; + } + if (roundPeriod < 1) + { + roundPeriod = 1; + } + switch (selector & 0x30) + { + case 0x00: + roundPhase = 0; + break; + case 0x10: + roundPhase = roundPeriod / 4; + break; + case 0x20: + roundPhase = roundPeriod / 2; + break; + default: + roundPhase = roundPeriod * 3 / 4; + break; + } + int n = selector & 0x0F; + roundThreshold = n == 0 ? roundPeriod - 1 : (n - 4) * roundPeriod / 8; + roundState = ROUND_SUPER; + roundOff = false; + } + + /** + * Rounds a distance according to the current round state. Engine compensation (the black/white/ + * grey distance bias FreeType applies) is treated as zero, which is correct for an anti-aliased + * Java2D target. + * + * @param distance the distance in F26Dot6 + * @return the rounded distance in F26Dot6 + */ + public int round(int distance) + { + if (roundOff) + { + return distance; + } + int val; + if (distance >= 0) + { + val = Math.floorDiv(distance - roundPhase + roundThreshold, roundPeriod) * roundPeriod; + if (val < 0) + { + val = 0; + } + val += roundPhase; + } + else + { + val = -(Math.floorDiv(roundThreshold - roundPhase - distance, roundPeriod) * roundPeriod); + if (val > 0) + { + val = 0; + } + val -= roundPhase; + } + return val; + } + + /** @return the minimum distance in F26Dot6 */ + public int getMinimumDistance() + { + return minimumDistance; + } + + /** @param value the minimum distance in F26Dot6 */ + public void setMinimumDistance(int value) + { + minimumDistance = value; + } + + /** @return the control value cut-in in F26Dot6 */ + public int getControlValueCutIn() + { + return controlValueCutIn; + } + + /** @param value the control value cut-in in F26Dot6 */ + public void setControlValueCutIn(int value) + { + controlValueCutIn = value; + } + + /** @return the single width cut-in in F26Dot6 */ + public int getSingleWidthCutIn() + { + return singleWidthCutIn; + } + + /** @param value the single width cut-in in F26Dot6 */ + public void setSingleWidthCutIn(int value) + { + singleWidthCutIn = value; + } + + /** @return the single width value in F26Dot6 */ + public int getSingleWidthValue() + { + return singleWidthValue; + } + + /** @param value the single width value in F26Dot6 */ + public void setSingleWidthValue(int value) + { + singleWidthValue = value; + } + + /** @return the delta base */ + public int getDeltaBase() + { + return deltaBase; + } + + /** @param value the delta base */ + public void setDeltaBase(int value) + { + deltaBase = value; + } + + /** @return the delta shift */ + public int getDeltaShift() + { + return deltaShift; + } + + /** @param value the delta shift */ + public void setDeltaShift(int value) + { + deltaShift = value; + } + + /** @return whether auto-flip is enabled */ + public boolean isAutoFlip() + { + return autoFlip; + } + + /** @param value whether auto-flip is enabled */ + public void setAutoFlip(boolean value) + { + autoFlip = value; + } + + /** @return the scan control flags */ + public int getScanControl() + { + return scanControl; + } + + /** @param value the scan control flags */ + public void setScanControl(int value) + { + scanControl = value; + } + + /** @return the scan type */ + public int getScanType() + { + return scanType; + } + + /** @param value the scan type */ + public void setScanType(int value) + { + scanType = value; + } + + /** @return the instruction control flags */ + public int getInstructControl() + { + return instructControl; + } + + /** @param value the instruction control flags */ + public void setInstructControl(int value) + { + instructControl = value; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/HintingException.java b/fontbox/src/main/java/org/apache/fontbox/ttf/HintingException.java new file mode 100644 index 00000000000..6ace71dc70e --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/HintingException.java @@ -0,0 +1,46 @@ +/* + * 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.fontbox.ttf; + +/** + * Thrown when the TrueType bytecode interpreter encounters a malformed or unsupported program. It is + * unchecked so that opcode handlers stay terse; the caller catches it per glyph and falls back to raw, + * unhinted coordinates rather than letting one bad glyph disable hinting for the whole font. + * + * @author Apache PDFBox + */ +class HintingException extends RuntimeException +{ + private static final long serialVersionUID = 1L; + + /** + * @param message describes the failure + */ + public HintingException(String message) + { + super(message); + } + + /** + * @param message describes the failure + * @param cause the underlying cause + */ + public HintingException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/TTFParser.java b/fontbox/src/main/java/org/apache/fontbox/ttf/TTFParser.java index 69a5a7e2c10..5a8de6d5fff 100644 --- a/fontbox/src/main/java/org/apache/fontbox/ttf/TTFParser.java +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/TTFParser.java @@ -377,6 +377,18 @@ private TTFTable readTableDirectory(TTFDataStream raf) throws IOException case GlyphSubstitutionTable.TAG: table = new GlyphSubstitutionTable(); break; + case ControlValueTable.TAG: + table = new ControlValueTable(); + break; + case FontProgramTable.TAG: + table = new FontProgramTable(); + break; + case ControlValueProgramTable.TAG: + table = new ControlValueProgramTable(); + break; + case GaspTable.TAG: + table = new GaspTable(); + break; default: table = readTable(tag); break; diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeFont.java b/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeFont.java index 90ecd9ece29..07ff1a00130 100644 --- a/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeFont.java +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeFont.java @@ -54,7 +54,9 @@ public class TrueTypeFont implements FontBoxFont, Closeable private final Object lockReadtable = new Object(); private final Object lockPSNames = new Object(); + private final Object lockHinter = new Object(); private final List enabledGsubFeatures = new ArrayList<>(); + private GlyphHinter hinter; /** * Constructor. Clients should use the TTFParser to create a new TrueTypeFont object. @@ -369,6 +371,50 @@ public GlyphSubstitutionTable getGsub() throws IOException return (GlyphSubstitutionTable) getTable(GlyphSubstitutionTable.TAG); } + /** + * Get the "cvt " (Control Value) table for this TTF. + * + * @return The "cvt " table or null if it doesn't exist. + * @throws IOException if there was an error reading the table. + */ + public ControlValueTable getControlValues() throws IOException + { + return (ControlValueTable) getTable(ControlValueTable.TAG); + } + + /** + * Get the "fpgm" (Font Program) table for this TTF. + * + * @return The "fpgm" table or null if it doesn't exist. + * @throws IOException if there was an error reading the table. + */ + public FontProgramTable getFontProgram() throws IOException + { + return (FontProgramTable) getTable(FontProgramTable.TAG); + } + + /** + * Get the "prep" (Control Value Program) table for this TTF. + * + * @return The "prep" table or null if it doesn't exist. + * @throws IOException if there was an error reading the table. + */ + public ControlValueProgramTable getControlValueProgram() throws IOException + { + return (ControlValueProgramTable) getTable(ControlValueProgramTable.TAG); + } + + /** + * Get the "gasp" (Grid-fitting And Scan-conversion Procedure) table for this TTF. + * + * @return The "gasp" table or null if it doesn't exist. + * @throws IOException if there was an error reading the table. + */ + public GaspTable getGasp() throws IOException + { + return (GaspTable) getTable(GaspTable.TAG); + } + /** * Get the data of the TrueType Font * program representing the stream used to build this @@ -789,6 +835,31 @@ public float getWidth(String name) throws IOException return getAdvanceWidth(gid); } + /** + * Returns the grid-fitted (hinted) path of the given glyph at the given ppem, in font units, or + * {@code null} if hinting does not apply (no bytecode program, a composite or empty glyph, or a + * ppem excluded by the gasp table). Whether to hint at all is the caller's decision, e.g. + * {@code PDFRenderer.setHintingEnabled(boolean)}; the caller should fall back to the raw outline + * ({@link GlyphData#getPath()}) when this returns {@code null}. + * + * @param gid the glyph id + * @param ppem the pixels-per-em to grid-fit to + * @return the hinted path in font units, or null + */ + public GeneralPath getHintedPath(int gid, int ppem) + { + GlyphHinter glyphHinter; + synchronized (lockHinter) + { + if (hinter == null) + { + hinter = new GlyphHinter(this); + } + glyphHinter = hinter; + } + return glyphHinter.getPath(gid, ppem); + } + @Override public boolean hasGlyph(String name) throws IOException { diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeInterpreter.java b/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeInterpreter.java new file mode 100644 index 00000000000..2fd52cc351e --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/TrueTypeInterpreter.java @@ -0,0 +1,1649 @@ +/* + * 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.fontbox.ttf; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** + * The TrueType bytecode interpreter: the VM driver and the 256-entry opcode dispatch table. + *

+ * It carries the execution engine - the {@link BytecodeStream} cursor driven dispatch loop, function + * definition and calling with a depth cap, branching, the push family, and the + * stack/arithmetic/logical/storage and point-moving opcodes - plus the size lifecycle: {@code fpgm} + * executed once, {@code prep} executed per ppem change with the result saved as the per-glyph + * template. An opcode with no handler and no {@code IDEF} binding throws {@link HintingException}, + * which the caller catches per glyph and falls back to the raw outline. + * + * @author Apache PDFBox + */ +class TrueTypeInterpreter +{ + /** Maximum {@code CALL}/{@code LOOPCALL} nesting depth, matching FreeType. */ + static final int MAX_CALL_DEPTH = 64; + + /** A single opcode handler. */ + @FunctionalInterface + private interface OpHandler + { + void execute(ExecutionContext ctx); + } + + // opcodes referenced by the engine itself (control flow / push) + private static final int NPUSHB = 0x40; + private static final int NPUSHW = 0x41; + private static final int PUSHB_BASE = 0xB0; + private static final int PUSHW_BASE = 0xB8; + private static final int ELSE = 0x1B; + private static final int IF = 0x58; + private static final int EIF = 0x59; + private static final int FDEF = 0x2C; + private static final int ENDF = 0x2D; + + private final OpHandler[] dispatch = new OpHandler[256]; + private final Map functions = new HashMap<>(); + private final Map instructionDefs = new HashMap<>(); + + private final int maxStackElements; + private final int unitsPerEm; + + // The storage area and twilight zone belong to the size, not to one program run: a font may seed + // them in prep and read them back from every glyph program. FreeType keeps both on the TT_Size and + // clears them in tt_size_run_prep, which is what setPpem does below. + private final int[] storage; + private final Zone twilightZone; + + private byte[] fontProgram; + private byte[] controlValueProgram; + private int[] rawControlValues = new int[0]; + private int[] scaledControlValues = new int[0]; + + private int ppem; + private int pointSize; + private GraphicsState savedState; + private ExecutionTracer tracer; + + /** + * @param maxStackElements operand stack capacity (from maxp) + * @param maxStorage storage area size (from maxp) + * @param maxTwilightPoints twilight zone size (from maxp) + * @param unitsPerEm the font's unitsPerEm (from head) + */ + public TrueTypeInterpreter(int maxStackElements, int maxStorage, int maxTwilightPoints, + int unitsPerEm) + { + this.maxStackElements = maxStackElements; + this.unitsPerEm = unitsPerEm; + this.storage = new int[Math.max(maxStorage, 0)]; + this.twilightZone = new Zone(Math.max(maxTwilightPoints, 0), 0); + buildDispatch(); + } + + // --- configuration --------------------------------------------------- + + /** @param program the raw {@code fpgm} bytecode, or null */ + public void setFontProgram(byte[] program) + { + this.fontProgram = program; + } + + /** @param program the raw {@code prep} bytecode, or null */ + public void setControlValueProgram(byte[] program) + { + this.controlValueProgram = program; + } + + /** @param values the raw control values in font units, or null */ + public void setControlValues(int[] values) + { + this.rawControlValues = values != null ? values : new int[0]; + } + + /** + * Attaches (or clears with {@code null}) an execution tracer that emits one FreeType-comparable + * line per executed instruction. Used by the trace-diff tooling to localize divergence; off in + * normal operation. + * + * @param tracer the tracer, or null to disable tracing + */ + public void setTracer(ExecutionTracer tracer) + { + this.tracer = tracer; + } + + // --- lifecycle ------------------------------------------------------- + + /** + * Runs the font program ({@code fpgm}) once, populating the function table. Safe to call when + * there is no font program. + */ + public void prepareFontProgram() + { + functions.clear(); + instructionDefs.clear(); + if (fontProgram == null || fontProgram.length == 0) + { + return; + } + ExecutionContext ctx = newContext(new GraphicsState()); + run(ctx, new BytecodeStream(fontProgram)); + } + + /** + * Establishes a new ppem: scales the control values, clears the storage area and twilight zone, runs + * the control value program ({@code prep}) from a default graphics state, and saves the resulting + * state as the per-glyph template. Whatever {@code prep} leaves in storage and the twilight zone + * stays there for every glyph hinted at this size, which is why they are cleared here and not per + * glyph - after FreeType's {@code tt_size_run_prep}. Anything the font program wrote to storage is + * discarded, again as FreeType does: {@code fpgm} is only meant to define functions. + * + * @param ppemValue the pixels-per-em to render at + * @param pointSizeValue the point size + */ + public void setPpem(int ppemValue, int pointSizeValue) + { + this.ppem = ppemValue; + this.pointSize = pointSizeValue; + scaleControlValues(); + Arrays.fill(storage, 0); + twilightZone.reset(); + + GraphicsState gs = new GraphicsState(); + if (controlValueProgram != null && controlValueProgram.length > 0) + { + ExecutionContext ctx = newContext(gs); + run(ctx, new BytecodeStream(controlValueProgram)); + } + savedState = gs; + } + + private void scaleControlValues() + { + scaledControlValues = new int[rawControlValues.length]; + for (int i = 0; i < rawControlValues.length; i++) + { + scaledControlValues[i] = Fixed.scale(rawControlValues[i], ppem, unitsPerEm); + } + } + + /** + * Builds a fresh execution context wired to this interpreter's sizes, scaled control values and the + * current ppem. The stack and per-run counters are new; the storage area and twilight zone are the + * interpreter's own, so values {@code prep} left there are visible to the glyph programs. + * + * @param gs the graphics state the context starts from + * @return a new execution context + */ + public ExecutionContext newContext(GraphicsState gs) + { + ExecutionContext ctx = new ExecutionContext(this, gs, maxStackElements, storage, + scaledControlValues, twilightZone); + ctx.setUnitsPerEm(unitsPerEm); + ctx.setPpem(ppem); + ctx.setPointSize(pointSize); + return ctx; + } + + /** + * Test/utility entry point: runs a standalone bytecode program from the saved (post-{@code prep}) + * state, or a default state if no size has been set, and returns the resulting context so callers + * can inspect the stack and state. + * + * @param program the bytecode to run + * @param ppemValue the ppem to run at + * @return the execution context after the program completes + */ + public ExecutionContext executeProgram(byte[] program, int ppemValue) + { + this.ppem = ppemValue; + GraphicsState gs = savedState != null ? savedState.copy() : new GraphicsState(); + ExecutionContext ctx = newContext(gs); + run(ctx, new BytecodeStream(program)); + return ctx; + } + + /** @return the saved post-{@code prep} graphics state, or null if no size has been set */ + public GraphicsState getSavedState() + { + return savedState; + } + + /** @return the function table populated by {@code fpgm} */ + public Map getFunctions() + { + return functions; + } + + // --- execution engine ------------------------------------------------ + + /** + * Runs a bytecode stream to completion (or until an {@code ENDF} returns from a function body), + * dispatching each opcode through the table. + * + * @param ctx the execution context + * @param s the stream to run + */ + public void run(ExecutionContext ctx, BytecodeStream s) + { + BytecodeStream previous = ctx.getStream(); + ctx.setStream(s); + try + { + while (s.hasNext() && !ctx.isReturnFromFunction()) + { + s.markInstructionStart(); + int opcode = s.nextByte(); + if (tracer != null) + { + tracer.trace(s.instructionStart(), opcode, ctx); + } + dispatch[opcode].execute(ctx); + } + } + finally + { + ctx.setStream(previous); + } + } + + /** + * Calls the function with the given number, running its body until the matching {@code ENDF}. + * + * @param ctx the execution context + * @param functionNumber the function to call + * @throws HintingException if the function is undefined or the call depth is exceeded + */ + public void callFunction(ExecutionContext ctx, int functionNumber) + { + FunctionDef def = functions.get(functionNumber); + if (def == null) + { + throw new HintingException("call to undefined function " + functionNumber); + } + callBody(ctx, def); + } + + /** Runs a function/instruction body from its entry point until the matching {@code ENDF}. */ + private void callBody(ExecutionContext ctx, FunctionDef def) + { + if (ctx.getCallDepth() >= MAX_CALL_DEPTH) + { + throw new HintingException("maximum call depth " + MAX_CALL_DEPTH + " exceeded"); + } + ctx.enterCall(); + try + { + BytecodeStream body = new BytecodeStream(def.getProgram()); + body.seek(def.getEntryPoint()); + run(ctx, body); + ctx.setReturnFromFunction(false); + } + finally + { + ctx.leaveCall(); + } + } + + private void defineFunction(ExecutionContext ctx) + { + int functionNumber = ctx.pop(); + BytecodeStream s = ctx.getStream(); + functions.put(functionNumber, new FunctionDef(s.getCode(), s.position())); + skipFunctionBody(s); + } + + /** IDEF: binds the opcode on top of the stack to the following instructions (until ENDF). */ + private void defineInstruction(ExecutionContext ctx) + { + int opcode = ctx.pop(); + BytecodeStream s = ctx.getStream(); + instructionDefs.put(opcode & 0xFF, new FunctionDef(s.getCode(), s.position())); + skipFunctionBody(s); + } + + private void skipFunctionBody(BytecodeStream s) + { + while (s.hasNext()) + { + int opcode = s.nextByte(); + if (opcode == ENDF) + { + return; + } + skipPushOperands(opcode, s); + } + throw new HintingException("FDEF without matching ENDF"); + } + + /** + * On a false {@code IF}, skips forward to the matching {@code ELSE} or {@code EIF}, accounting for + * nested {@code IF} blocks and for the inline operands of push instructions. Leaves the stream + * positioned just after the terminator. + */ + private void skipToElseOrEif(BytecodeStream s) + { + int depth = 0; + while (s.hasNext()) + { + int opcode = s.nextByte(); + if (opcode == IF) + { + depth++; + } + else if (opcode == EIF) + { + if (depth == 0) + { + return; + } + depth--; + } + else if (opcode == ELSE && depth == 0) + { + return; + } + else + { + skipPushOperands(opcode, s); + } + } + throw new HintingException("IF without matching EIF"); + } + + /** + * After a true {@code IF} branch reaches its {@code ELSE}, skips the else-branch to the matching + * {@code EIF}. + */ + private void skipToEif(BytecodeStream s) + { + int depth = 0; + while (s.hasNext()) + { + int opcode = s.nextByte(); + if (opcode == IF) + { + depth++; + } + else if (opcode == EIF) + { + if (depth == 0) + { + return; + } + depth--; + } + else + { + skipPushOperands(opcode, s); + } + } + throw new HintingException("ELSE without matching EIF"); + } + + /** Advances the stream past the inline operands of a push opcode; a no-op for other opcodes. */ + private void skipPushOperands(int opcode, BytecodeStream s) + { + if (opcode == NPUSHB) + { + s.skip(s.nextByte()); + } + else if (opcode == NPUSHW) + { + s.skip(2 * s.nextByte()); + } + else if (opcode >= PUSHB_BASE && opcode <= PUSHB_BASE + 7) + { + s.skip(opcode - PUSHB_BASE + 1); + } + else if (opcode >= PUSHW_BASE && opcode <= PUSHW_BASE + 7) + { + s.skip(2 * (opcode - PUSHW_BASE + 1)); + } + } + + // --- dispatch table -------------------------------------------------- + + private void buildDispatch() + { + for (int i = 0; i < dispatch.length; i++) + { + final int opcode = i; + dispatch[i] = ctx -> + { + // an opcode with no built-in handler may have been given one by IDEF + FunctionDef def = instructionDefs.get(opcode); + if (def != null) + { + callBody(ctx, def); + return; + } + throw new HintingException( + String.format("unsupported TrueType opcode 0x%02X", opcode)); + }; + } + + installPushOps(); + installStackOps(); + installArithmeticOps(); + installLogicalOps(); + installFlowOps(); + installStateOps(); + installStorageAndCvtOps(); + installMiscOps(); + installVectorOps(); + installRoundOps(); + installPointOps(); + installInterpolationOps(); + installMeasureOps(); + installDeltaOps(); + installFlipOps(); + } + + private void installPushOps() + { + dispatch[NPUSHB] = ctx -> + { + int n = ctx.getStream().nextByte(); + for (int i = 0; i < n; i++) + { + ctx.push(ctx.getStream().nextByte()); + } + }; + dispatch[NPUSHW] = ctx -> + { + int n = ctx.getStream().nextByte(); + for (int i = 0; i < n; i++) + { + ctx.push(ctx.getStream().nextWord()); + } + }; + for (int k = 0; k < 8; k++) + { + final int count = k + 1; + dispatch[PUSHB_BASE + k] = ctx -> + { + for (int i = 0; i < count; i++) + { + ctx.push(ctx.getStream().nextByte()); + } + }; + dispatch[PUSHW_BASE + k] = ctx -> + { + for (int i = 0; i < count; i++) + { + ctx.push(ctx.getStream().nextWord()); + } + }; + } + } + + private void installStackOps() + { + dispatch[0x20] = ctx -> ctx.push(ctx.peek(0)); // DUP + dispatch[0x21] = ExecutionContext::pop; // POP + dispatch[0x22] = ExecutionContext::clearStack; // CLEAR + dispatch[0x23] = ctx -> // SWAP + { + int a = ctx.pop(); + int b = ctx.pop(); + ctx.push(a); + ctx.push(b); + }; + dispatch[0x24] = ctx -> ctx.push(ctx.getStackDepth()); // DEPTH + dispatch[0x25] = ctx -> ctx.push(ctx.peek(ctx.pop() - 1)); // CINDEX + dispatch[0x26] = ctx -> // MINDEX + { + int k = ctx.pop(); + int[] tmp = new int[k]; + for (int i = 0; i < k; i++) + { + tmp[i] = ctx.pop(); + } + for (int i = k - 2; i >= 0; i--) + { + ctx.push(tmp[i]); + } + ctx.push(tmp[k - 1]); + }; + dispatch[0x8A] = ctx -> // ROLL + { + int c = ctx.pop(); + int b = ctx.pop(); + int a = ctx.pop(); + ctx.push(b); + ctx.push(c); + ctx.push(a); + }; + } + + private void installArithmeticOps() + { + dispatch[0x60] = ctx -> binary(ctx, (a, b) -> a + b); // ADD + dispatch[0x61] = ctx -> binary(ctx, (a, b) -> a - b); // SUB + dispatch[0x62] = ctx -> binary(ctx, Fixed::div); // DIV + dispatch[0x63] = ctx -> binary(ctx, Fixed::mul); // MUL + dispatch[0x64] = ctx -> ctx.push(Math.abs(ctx.pop())); // ABS + dispatch[0x65] = ctx -> ctx.push(-ctx.pop()); // NEG + dispatch[0x66] = ctx -> ctx.push(Fixed.floor(ctx.pop())); // FLOOR + dispatch[0x67] = ctx -> ctx.push(Fixed.ceil(ctx.pop())); // CEILING + dispatch[0x8B] = ctx -> binary(ctx, Math::max); // MAX + dispatch[0x8C] = ctx -> binary(ctx, Math::min); // MIN + } + + private void installLogicalOps() + { + dispatch[0x50] = ctx -> binary(ctx, (a, b) -> bool(a < b)); // LT + dispatch[0x51] = ctx -> binary(ctx, (a, b) -> bool(a <= b)); // LTEQ + dispatch[0x52] = ctx -> binary(ctx, (a, b) -> bool(a > b)); // GT + dispatch[0x53] = ctx -> binary(ctx, (a, b) -> bool(a >= b)); // GTEQ + dispatch[0x54] = ctx -> binary(ctx, (a, b) -> bool(a == b)); // EQ + dispatch[0x55] = ctx -> binary(ctx, (a, b) -> bool(a != b)); // NEQ + dispatch[0x56] = ctx -> ctx.push(bool(((Fixed.round(ctx.pop()) >> 6) & 1) != 0)); // ODD + dispatch[0x57] = ctx -> ctx.push(bool(((Fixed.round(ctx.pop()) >> 6) & 1) == 0)); // EVEN + dispatch[0x5A] = ctx -> binary(ctx, (a, b) -> bool(a != 0 && b != 0)); // AND + dispatch[0x5B] = ctx -> binary(ctx, (a, b) -> bool(a != 0 || b != 0)); // OR + dispatch[0x5C] = ctx -> ctx.push(bool(ctx.pop() == 0)); // NOT + } + + private void installFlowOps() + { + dispatch[IF] = ctx -> + { + if (ctx.pop() == 0) + { + skipToElseOrEif(ctx.getStream()); + } + }; + dispatch[ELSE] = ctx -> skipToEif(ctx.getStream()); + dispatch[EIF] = ctx -> { /* no-op terminator */ }; + dispatch[0x1C] = ctx -> jump(ctx, ctx.pop()); // JMPR + dispatch[0x78] = ctx -> // JROT + { + int e = ctx.pop(); + int offset = ctx.pop(); + if (e != 0) + { + jump(ctx, offset); + } + }; + dispatch[0x79] = ctx -> // JROF + { + int e = ctx.pop(); + int offset = ctx.pop(); + if (e == 0) + { + jump(ctx, offset); + } + }; + dispatch[FDEF] = this::defineFunction; + dispatch[ENDF] = ctx -> ctx.setReturnFromFunction(true); + dispatch[0x89] = this::defineInstruction; // IDEF + dispatch[0x2B] = ctx -> callFunction(ctx, ctx.pop()); // CALL + dispatch[0x2A] = ctx -> // LOOPCALL + { + int functionNumber = ctx.pop(); + int count = ctx.pop(); + // the spec calls the count unsigned; FreeType runs nothing at all when it is not positive + if (count <= 0) + { + return; + } + // charge the whole loop up front, so an absurd count fails before a single iteration runs + ctx.countLoopCalls(count); + for (int i = 0; i < count; i++) + { + callFunction(ctx, functionNumber); + } + }; + } + + /** + * Jumps to {@code offset} bytes from the start of the current instruction. A backward jump is the + * only way TrueType bytecode can loop other than {@code LOOPCALL}, so those are counted against the + * run's budget and the program is abandoned once it exceeds it. + * + * @param ctx the execution context + * @param offset the jump offset, relative to the current instruction + * @throws HintingException if the stream position is out of range, or too many backward jumps + */ + private static void jump(ExecutionContext ctx, int offset) + { + if (offset < 0) + { + ctx.countNegativeJump(); + } + BytecodeStream s = ctx.getStream(); + s.seek(s.instructionStart() + offset); + } + + private void installStateOps() + { + dispatch[0x17] = ctx -> ctx.getGraphicsState().setLoop(ctx.pop()); // SLOOP + dispatch[0x10] = ctx -> ctx.getGraphicsState().setRp0(ctx.pop()); // SRP0 + dispatch[0x11] = ctx -> ctx.getGraphicsState().setRp1(ctx.pop()); // SRP1 + dispatch[0x12] = ctx -> ctx.getGraphicsState().setRp2(ctx.pop()); // SRP2 + dispatch[0x1A] = ctx -> ctx.getGraphicsState().setMinimumDistance(ctx.pop()); // SMD + dispatch[0x5E] = ctx -> ctx.getGraphicsState().setDeltaBase(ctx.pop()); // SDB + dispatch[0x5F] = ctx -> ctx.getGraphicsState().setDeltaShift(ctx.pop()); // SDS + dispatch[0x1D] = ctx -> ctx.getGraphicsState().setControlValueCutIn(ctx.pop()); // SCVTCI + dispatch[0x1E] = ctx -> ctx.getGraphicsState().setSingleWidthCutIn(ctx.pop()); // SSWCI + dispatch[0x1F] = ctx -> ctx.getGraphicsState().setSingleWidthValue(ctx.pop()); // SSW + + dispatch[0x18] = roundState(GraphicsState.ROUND_TO_GRID); // RTG + dispatch[0x19] = roundState(GraphicsState.ROUND_TO_HALF_GRID); // RTHG + dispatch[0x3D] = roundState(GraphicsState.ROUND_TO_DOUBLE_GRID); // RTDG + dispatch[0x7C] = roundState(GraphicsState.ROUND_UP_TO_GRID); // RUTG + dispatch[0x7D] = roundState(GraphicsState.ROUND_DOWN_TO_GRID); // RDTG + dispatch[0x7A] = roundState(GraphicsState.ROUND_OFF); // ROFF + dispatch[0x76] = ctx -> ctx.getGraphicsState().setSuperRound(Fixed.ONE, ctx.pop()); // SROUND + // S45ROUND: grid period is the 45-degree diagonal, sqrt(2)/2 px ~= 45 in F26Dot6 + dispatch[0x77] = ctx -> ctx.getGraphicsState().setSuperRound(45, ctx.pop()); // S45ROUND + dispatch[0x13] = ctx -> ctx.getGraphicsState().setZp0(ctx.pop()); // SZP0 + dispatch[0x14] = ctx -> ctx.getGraphicsState().setZp1(ctx.pop()); // SZP1 + dispatch[0x15] = ctx -> ctx.getGraphicsState().setZp2(ctx.pop()); // SZP2 + dispatch[0x16] = ctx -> // SZPS + { + int zone = ctx.pop(); + GraphicsState gs = ctx.getGraphicsState(); + gs.setZp0(zone); + gs.setZp1(zone); + gs.setZp2(zone); + }; + } + + private void installStorageAndCvtOps() + { + dispatch[0x43] = ctx -> // RS + { + int index = ctx.pop(); + ctx.push(read(ctx.getStorage(), index, "storage")); + }; + dispatch[0x42] = ctx -> // WS + { + int value = ctx.pop(); + int index = ctx.pop(); + write(ctx.getStorage(), index, value, "storage"); + }; + dispatch[0x45] = ctx -> // RCVT + { + int index = ctx.pop(); + ctx.push(read(ctx.getControlValues(), index, "cvt")); + }; + dispatch[0x44] = ctx -> // WCVTP + { + int value = ctx.pop(); + int index = ctx.pop(); + write(ctx.getControlValues(), index, value, "cvt"); + }; + dispatch[0x70] = ctx -> // WCVTF + { + int value = ctx.pop(); + int index = ctx.pop(); + write(ctx.getControlValues(), index, + Fixed.scale(value, ctx.getPpem(), ctx.getUnitsPerEm()), "cvt"); + }; + } + + private void installMiscOps() + { + dispatch[0x4B] = ctx -> ctx.push(ctx.getPpem()); // MPPEM + dispatch[0x4C] = ctx -> ctx.push(ctx.getPointSize()); // MPS + dispatch[0x4F] = ExecutionContext::pop; // DEBUG (pops, no-op) + dispatch[0x7E] = ExecutionContext::pop; // SANGW (deprecated, pops) + dispatch[0x7F] = ctx -> { /* AA - deprecated no-op */ }; // AA + dispatch[0x88] = ctx -> // GETINFO + { + int selector = ctx.pop(); + int result = 0; + if ((selector & 0x0001) != 0) + { + // rasterizer version 40: FreeType's "minimal" subpixel interpreter, which we mirror + // for grayscale antialiased rendering (lighter stems than the classic v35) + result |= 40; + } + // we always render grayscale-subpixel ("lean"), non-LCD: report the subpixel bits a v40 + // grayscale rasterizer returns so fonts take their lighter ClearType-aware code paths. + // (the grayscale bit 12 is intentionally not set: FreeType clears exc->grayscale in lean mode) + if ((selector & 0x0040) != 0) + { + result |= 1 << 13; // subpixel hinting active + } + if ((selector & 0x0400) != 0) + { + result |= 1 << 17; // ClearType hinting active + } + if ((selector & 0x0800) != 0) + { + result |= 1 << 18; // subpixel positioned + } + if ((selector & 0x1000) != 0) + { + result |= 1 << 19; // grayscale ClearType + } + ctx.push(result); + }; + } + + // --- vector setters -------------------------------------------------- + + private void installVectorOps() + { + dispatch[0x00] = ctx -> setProjAndFreedomAxis(ctx, false); // SVTCA[0] y + dispatch[0x01] = ctx -> setProjAndFreedomAxis(ctx, true); // SVTCA[1] x + dispatch[0x02] = ctx -> setProjectionAxis(ctx, false); // SPVTCA[0] y + dispatch[0x03] = ctx -> setProjectionAxis(ctx, true); // SPVTCA[1] x + dispatch[0x04] = ctx -> setFreedomAxis(ctx, false); // SFVTCA[0] y + dispatch[0x05] = ctx -> setFreedomAxis(ctx, true); // SFVTCA[1] x + dispatch[0x06] = ctx -> setProjectionToLine(ctx, false); // SPVTL[0] parallel + dispatch[0x07] = ctx -> setProjectionToLine(ctx, true); // SPVTL[1] perpendicular + dispatch[0x08] = ctx -> setFreedomToLine(ctx, false); // SFVTL[0] parallel + dispatch[0x09] = ctx -> setFreedomToLine(ctx, true); // SFVTL[1] perpendicular + dispatch[0x86] = ctx -> setDualProjectionToLine(ctx, false); // SDPVTL[0] + dispatch[0x87] = ctx -> setDualProjectionToLine(ctx, true); // SDPVTL[1] + dispatch[0x0E] = ctx -> // SFVTPV + { + UnitVector pv = ctx.getGraphicsState().getProjectionVector(); + ctx.getGraphicsState().getFreedomVector().set(pv.getX(), pv.getY()); + }; + dispatch[0x0A] = ctx -> // SPVFS + { + int y = ctx.pop(); + int x = ctx.pop(); + ctx.getGraphicsState().getProjectionVector().set(x, y); + ctx.getGraphicsState().getDualProjectionVector().set(x, y); + }; + dispatch[0x0B] = ctx -> // SFVFS + { + int y = ctx.pop(); + int x = ctx.pop(); + ctx.getGraphicsState().getFreedomVector().set(x, y); + }; + dispatch[0x0C] = ctx -> // GPV + { + UnitVector pv = ctx.getGraphicsState().getProjectionVector(); + ctx.push(pv.getX()); + ctx.push(pv.getY()); + }; + dispatch[0x0D] = ctx -> // GFV + { + UnitVector fv = ctx.getGraphicsState().getFreedomVector(); + ctx.push(fv.getX()); + ctx.push(fv.getY()); + }; + } + + private static void setProjAndFreedomAxis(ExecutionContext ctx, boolean xAxis) + { + setProjectionAxis(ctx, xAxis); + setFreedomAxis(ctx, xAxis); + } + + private static void setProjectionAxis(ExecutionContext ctx, boolean xAxis) + { + int x = xAxis ? Fixed.ONE_F2DOT14 : 0; + int y = xAxis ? 0 : Fixed.ONE_F2DOT14; + ctx.getGraphicsState().getProjectionVector().set(x, y); + ctx.getGraphicsState().getDualProjectionVector().set(x, y); + } + + private static void setFreedomAxis(ExecutionContext ctx, boolean xAxis) + { + int x = xAxis ? Fixed.ONE_F2DOT14 : 0; + int y = xAxis ? 0 : Fixed.ONE_F2DOT14; + ctx.getGraphicsState().getFreedomVector().set(x, y); + } + + private void setProjectionToLine(ExecutionContext ctx, boolean perpendicular) + { + UnitVector[] v = lineVectors(ctx, perpendicular); + ctx.getGraphicsState().getProjectionVector().set(v[0].getX(), v[0].getY()); + ctx.getGraphicsState().getDualProjectionVector().set(v[1].getX(), v[1].getY()); + } + + private void setFreedomToLine(ExecutionContext ctx, boolean perpendicular) + { + UnitVector[] v = lineVectors(ctx, perpendicular); + ctx.getGraphicsState().getFreedomVector().set(v[0].getX(), v[0].getY()); + } + + private void setDualProjectionToLine(ExecutionContext ctx, boolean perpendicular) + { + UnitVector[] v = lineVectors(ctx, perpendicular); + ctx.getGraphicsState().getProjectionVector().set(v[0].getX(), v[0].getY()); + ctx.getGraphicsState().getDualProjectionVector().set(v[1].getX(), v[1].getY()); + } + + /** + * Pops two point numbers and returns {current-based, original-based} unit vectors along (or + * perpendicular to) the line between them. The first point is taken from zp2, the second from zp1. + */ + private UnitVector[] lineVectors(ExecutionContext ctx, boolean perpendicular) + { + GraphicsState gs = ctx.getGraphicsState(); + int p2 = ctx.pop(); + int p1 = ctx.pop(); + Zone z1 = ctx.getZone(gs.getZp2()); + Zone z2 = ctx.getZone(gs.getZp1()); + UnitVector current = UnitVector.normalize(z2.getCurrentX()[p2] - z1.getCurrentX()[p1], + z2.getCurrentY()[p2] - z1.getCurrentY()[p1]); + UnitVector original = UnitVector.normalize(z2.getOriginalX()[p2] - z1.getOriginalX()[p1], + z2.getOriginalY()[p2] - z1.getOriginalY()[p1]); + if (perpendicular) + { + current = current.perpendicular(); + original = original.perpendicular(); + } + return new UnitVector[] { current, original }; + } + + // --- rounding opcodes ------------------------------------------------ + + private void installRoundOps() + { + for (int k = 0; k < 4; k++) + { + dispatch[0x68 + k] = ctx -> ctx.push(ctx.getGraphicsState().round(ctx.pop())); // ROUND[ab] + dispatch[0x6C + k] = ctx -> ctx.push(ctx.pop()); // NROUND[ab] + } + } + + // --- point movement -------------------------------------------------- + + private void installPointOps() + { + dispatch[0x0F] = this::doIsect; // ISECT + dispatch[0x2E] = ctx -> doMDAP(ctx, false); // MDAP[0] no round + dispatch[0x2F] = ctx -> doMDAP(ctx, true); // MDAP[1] round + dispatch[0x3E] = ctx -> doMIAP(ctx, false); // MIAP[0] no round + dispatch[0x3F] = ctx -> doMIAP(ctx, true); // MIAP[1] round + cut-in + dispatch[0x3A] = ctx -> doMSIRP(ctx, false); // MSIRP[0] + dispatch[0x3B] = ctx -> doMSIRP(ctx, true); // MSIRP[1] set rp0 + dispatch[0x3C] = this::doAlignRp; // ALIGNRP + dispatch[0x27] = this::doAlignPts; // ALIGNPTS + dispatch[0x29] = this::doUtp; // UTP + dispatch[0x38] = this::doShpix; // SHPIX + dispatch[0x32] = ctx -> doShp(ctx, false); // SHP[0] rp2/zp1 + dispatch[0x33] = ctx -> doShp(ctx, true); // SHP[1] rp1/zp0 + dispatch[0x34] = ctx -> doShc(ctx, false); // SHC[0] + dispatch[0x35] = ctx -> doShc(ctx, true); // SHC[1] + dispatch[0x36] = ctx -> doShz(ctx, false); // SHZ[0] + dispatch[0x37] = ctx -> doShz(ctx, true); // SHZ[1] + for (int op = 0xC0; op <= 0xDF; op++) + { + final int code = op; + dispatch[op] = ctx -> doMDRP(ctx, code); // MDRP[abcde] + } + for (int op = 0xE0; op <= 0xFF; op++) + { + final int code = op; + dispatch[op] = ctx -> doMIRP(ctx, code); // MIRP[abcde] + } + } + + /** + * ISECT: moves a point to the intersection of line A (a0,a1 in zp1) and line B (b0,b1 in zp0). + * Mirrors FreeType's Ins_ISECT, including the parallel-lines fallback to the four-point average. + */ + private void doIsect(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + int b1 = ctx.pop(); + int b0 = ctx.pop(); + int a1 = ctx.pop(); + int a0 = ctx.pop(); + int point = ctx.pop(); + Zone za = ctx.getZone(gs.getZp1()); + Zone zb = ctx.getZone(gs.getZp0()); + Zone zp = ctx.getZone(gs.getZp2()); + + int a0x = za.getCurrentX()[a0]; + int a0y = za.getCurrentY()[a0]; + int dax = za.getCurrentX()[a1] - a0x; + int day = za.getCurrentY()[a1] - a0y; + int b0x = zb.getCurrentX()[b0]; + int b0y = zb.getCurrentY()[b0]; + int dbx = zb.getCurrentX()[b1] - b0x; + int dby = zb.getCurrentY()[b1] - b0y; + int dx = b0x - a0x; + int dy = b0y - a0y; + + int discriminant = Fixed.mulDiv(dax, -dby, 0x40) + Fixed.mulDiv(day, dbx, 0x40); + int dotproduct = Fixed.mulDiv(dax, dbx, 0x40) + Fixed.mulDiv(day, dby, 0x40); + + // reject grazing intersections of nearly parallel lines, as FreeType does + if (Math.abs((long) discriminant * 0x40) > Math.abs((long) dotproduct)) + { + int val = Fixed.mulDiv(dx, -dby, 0x40) + Fixed.mulDiv(dy, dbx, 0x40); + zp.getCurrentX()[point] = a0x + Fixed.mulDiv(val, dax, discriminant); + zp.getCurrentY()[point] = a0y + Fixed.mulDiv(val, day, discriminant); + } + else + { + // parallel: average of the four line points + zp.getCurrentX()[point] = (a0x + za.getCurrentX()[a1] + b0x + zb.getCurrentX()[b1]) / 2 / 2; + zp.getCurrentY()[point] = (a0y + za.getCurrentY()[a1] + b0y + zb.getCurrentY()[b1]) / 2 / 2; + } + zp.getTouchedX()[point] = true; + zp.getTouchedY()[point] = true; + } + + private void doMDAP(ExecutionContext ctx, boolean round) + { + GraphicsState gs = ctx.getGraphicsState(); + int point = ctx.pop(); + Zone zone = ctx.getZone(gs.getZp0()); + int cur = ctx.project(zone.getCurrentX()[point], zone.getCurrentY()[point]); + int distance = round ? gs.round(cur) : cur; + ctx.movePoint(zone, point, distance - cur); + gs.setRp0(point); + gs.setRp1(point); + } + + private void doMIAP(ExecutionContext ctx, boolean round) + { + GraphicsState gs = ctx.getGraphicsState(); + int cvtIndex = ctx.pop(); + int point = ctx.pop(); + Zone zone = ctx.getZone(gs.getZp0()); + int[] cvt = ctx.getControlValues(); + int value = cvtIndex >= 0 && cvtIndex < cvt.length ? cvt[cvtIndex] : 0; + + if (gs.getZp0() == 0) + { + // twilight point: establish its position from the control value along the projection + UnitVector pv = gs.getProjectionVector(); + zone.getOriginalX()[point] = Fixed.mul14(value, pv.getX()); + zone.getOriginalY()[point] = Fixed.mul14(value, pv.getY()); + zone.getCurrentX()[point] = zone.getOriginalX()[point]; + zone.getCurrentY()[point] = zone.getOriginalY()[point]; + } + int cur = ctx.project(zone.getCurrentX()[point], zone.getCurrentY()[point]); + if (round) + { + if (Math.abs(value - cur) > gs.getControlValueCutIn()) + { + value = cur; + } + value = gs.round(value); + } + ctx.movePoint(zone, point, value - cur); + gs.setRp0(point); + gs.setRp1(point); + } + + private void doMSIRP(ExecutionContext ctx, boolean setRp0) + { + GraphicsState gs = ctx.getGraphicsState(); + int distance = ctx.pop(); + int point = ctx.pop(); + Zone zp1 = ctx.getZone(gs.getZp1()); + Zone zp0 = ctx.getZone(gs.getZp0()); + int rp0 = gs.getRp0(); + int curDist = ctx.projectedDistance(zp1, point, zp0, rp0); + ctx.movePoint(zp1, point, distance - curDist); + gs.setRp1(rp0); + gs.setRp2(point); + if (setRp0) + { + gs.setRp0(point); + } + } + + private void doAlignRp(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + Zone zp1 = ctx.getZone(gs.getZp1()); + Zone zp0 = ctx.getZone(gs.getZp0()); + int rp0 = gs.getRp0(); + forEachLoopPoint(ctx, point -> + { + int dist = ctx.projectedDistance(zp1, point, zp0, rp0); + ctx.movePoint(zp1, point, -dist); + }); + } + + private void doAlignPts(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + int p2 = ctx.pop(); + int p1 = ctx.pop(); + Zone zp1 = ctx.getZone(gs.getZp1()); + Zone zp0 = ctx.getZone(gs.getZp0()); + int distance = ctx.projectedDistance(zp0, p1, zp1, p2); + // move both points to the midpoint of their projected positions + ctx.movePoint(zp1, p2, distance / 2); + ctx.movePoint(zp0, p1, -(distance - distance / 2)); + } + + private void doUtp(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + Zone zone = ctx.getZone(gs.getZp0()); + UnitVector fv = gs.getFreedomVector(); + int point = ctx.pop(); + if (fv.getX() != 0) + { + zone.getTouchedX()[point] = false; + } + if (fv.getY() != 0) + { + zone.getTouchedY()[point] = false; + } + } + + private void doShpix(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + int amount = ctx.pop(); + Zone zp2 = ctx.getZone(gs.getZp2()); + forEachLoopPoint(ctx, point -> ctx.movePoint(zp2, point, amount)); + } + + private void doShp(ExecutionContext ctx, boolean useRp1) + { + GraphicsState gs = ctx.getGraphicsState(); + int ref = referencePoint(gs, useRp1); + Zone refZone = referenceZone(ctx, useRp1); + int shift = referenceShift(ctx, refZone, ref); + Zone zp2 = ctx.getZone(gs.getZp2()); + forEachLoopPoint(ctx, point -> + { + if (!(refZone == zp2 && point == ref)) + { + ctx.movePoint(zp2, point, shift); + } + }); + } + + private void doShc(ExecutionContext ctx, boolean useRp1) + { + GraphicsState gs = ctx.getGraphicsState(); + int ref = referencePoint(gs, useRp1); + Zone refZone = referenceZone(ctx, useRp1); + int shift = referenceShift(ctx, refZone, ref); + int contour = ctx.pop(); + Zone zp2 = ctx.getZone(gs.getZp2()); + int[] ends = zp2.getContourEnds(); + if (contour < 0 || contour >= ends.length) + { + return; + } + int start = contour == 0 ? 0 : ends[contour - 1] + 1; + for (int i = start; i <= ends[contour]; i++) + { + // FreeType's SHC does not move the reference point itself (it has already moved) + if (!(refZone == zp2 && i == ref)) + { + ctx.movePoint(zp2, i, shift); + } + } + } + + private void doShz(ExecutionContext ctx, boolean useRp1) + { + GraphicsState gs = ctx.getGraphicsState(); + int ref = referencePoint(gs, useRp1); + Zone refZone = referenceZone(ctx, useRp1); + int shift = referenceShift(ctx, refZone, ref); + int zoneNumber = ctx.pop(); + Zone zone = ctx.getZone(zoneNumber); + for (int i = 0; i < zone.getPointCount(); i++) + { + if (!(refZone == zone && i == ref)) + { + ctx.movePoint(zone, i, shift); + } + } + } + + private static int referencePoint(GraphicsState gs, boolean useRp1) + { + return useRp1 ? gs.getRp1() : gs.getRp2(); + } + + private static Zone referenceZone(ExecutionContext ctx, boolean useRp1) + { + GraphicsState gs = ctx.getGraphicsState(); + return ctx.getZone(useRp1 ? gs.getZp0() : gs.getZp1()); + } + + /** The projected distance the reference point (rp1 in zp0, or rp2 in zp1) has been moved. */ + private static int referenceShift(ExecutionContext ctx, Zone refZone, int ref) + { + return ctx.project(refZone.getCurrentX()[ref] - refZone.getOriginalX()[ref], + refZone.getCurrentY()[ref] - refZone.getOriginalY()[ref]); + } + + private void doMDRP(ExecutionContext ctx, int op) + { + GraphicsState gs = ctx.getGraphicsState(); + int flags = op & 0x1F; + boolean setRp0 = (flags & 0x10) != 0; + boolean useMin = (flags & 0x08) != 0; + boolean round = (flags & 0x04) != 0; + int point = ctx.pop(); + Zone zp1 = ctx.getZone(gs.getZp1()); + Zone zp0 = ctx.getZone(gs.getZp0()); + int rp0 = gs.getRp0(); + + int orgDist = ctx.dualProjectedDistance(zp1, point, zp0, rp0); + orgDist = applySingleWidth(gs, orgDist); + int distance = round ? gs.round(orgDist) : orgDist; + distance = applyMinimumDistance(gs, useMin, orgDist, distance); + + int curDist = ctx.projectedDistance(zp1, point, zp0, rp0); + ctx.movePoint(zp1, point, distance - curDist); + gs.setRp1(rp0); + gs.setRp2(point); + if (setRp0) + { + gs.setRp0(point); + } + } + + private void doMIRP(ExecutionContext ctx, int op) + { + GraphicsState gs = ctx.getGraphicsState(); + int flags = op & 0x1F; + boolean setRp0 = (flags & 0x10) != 0; + boolean useMin = (flags & 0x08) != 0; + boolean round = (flags & 0x04) != 0; + // the CVT entry number is on top of the stack, the point number below it + int cvtIndex = ctx.pop(); + int point = ctx.pop(); + int[] cvt = ctx.getControlValues(); + int cvtValue = cvtIndex >= 0 && cvtIndex < cvt.length ? cvt[cvtIndex] : 0; + cvtValue = applySingleWidth(gs, cvtValue); + + Zone zp1 = ctx.getZone(gs.getZp1()); + Zone zp0 = ctx.getZone(gs.getZp0()); + int rp0 = gs.getRp0(); + int orgDist = ctx.dualProjectedDistance(zp1, point, zp0, rp0); + + // auto-flip the control value to match the sign of the original distance + if (gs.isAutoFlip() && (orgDist ^ cvtValue) < 0) + { + cvtValue = -cvtValue; + } + int distance; + if (round) + { + // the control value cut-in only applies when both points are in the same zone + if (gs.getZp0() == gs.getZp1() + && Math.abs(cvtValue - orgDist) > gs.getControlValueCutIn()) + { + cvtValue = orgDist; + } + distance = gs.round(cvtValue); + } + else + { + distance = cvtValue; + } + distance = applyMinimumDistance(gs, useMin, orgDist, distance); + + int curDist = ctx.projectedDistance(zp1, point, zp0, rp0); + ctx.movePoint(zp1, point, distance - curDist); + gs.setRp1(rp0); + gs.setRp2(point); + if (setRp0) + { + gs.setRp0(point); + } + } + + private static int applySingleWidth(GraphicsState gs, int distance) + { + if (Math.abs(distance - gs.getSingleWidthValue()) < gs.getSingleWidthCutIn()) + { + return distance >= 0 ? gs.getSingleWidthValue() : -gs.getSingleWidthValue(); + } + return distance; + } + + private static int applyMinimumDistance(GraphicsState gs, boolean useMin, int orgDist, + int distance) + { + if (!useMin) + { + return distance; + } + int md = gs.getMinimumDistance(); + if (orgDist >= 0) + { + return distance < md ? md : distance; + } + return distance > -md ? -md : distance; + } + + // --- interpolation --------------------------------------------------- + + private void installInterpolationOps() + { + dispatch[0x30] = ctx -> doIup(ctx, false); // IUP[0] y + dispatch[0x31] = ctx -> doIup(ctx, true); // IUP[1] x + dispatch[0x39] = this::doIp; // IP + } + + private void doIup(ExecutionContext ctx, boolean xAxis) + { + // record that IUP ran on this axis; under backward-compatibility, once both axes are done the + // glyph is frozen against further y moves (see ExecutionContext.movePoint) + if (xAxis) + { + ctx.setIupxCalled(); + } + else + { + ctx.setIupyCalled(); + } + // IUP always operates on the glyph zone, directly on the x or y coordinate + Zone zone = ctx.getZone(1); + int[] cur = xAxis ? zone.getCurrentX() : zone.getCurrentY(); + int[] org = xAxis ? zone.getOriginalX() : zone.getOriginalY(); + boolean[] touched = xAxis ? zone.getTouchedX() : zone.getTouchedY(); + int[] ends = zone.getContourEnds(); + int start = 0; + for (int end : ends) + { + interpolateContour(cur, org, touched, start, end); + start = end + 1; + } + } + + private static void interpolateContour(int[] cur, int[] org, boolean[] touched, int start, + int end) + { + if (end < start) + { + return; + } + int firstTouched = -1; + int touchedCount = 0; + for (int i = start; i <= end; i++) + { + if (touched[i]) + { + if (firstTouched < 0) + { + firstTouched = i; + } + touchedCount++; + } + } + if (touchedCount == 0) + { + return; + } + if (touchedCount == 1) + { + int delta = cur[firstTouched] - org[firstTouched]; + if (delta != 0) + { + for (int i = start; i <= end; i++) + { + if (i != firstTouched) + { + cur[i] = org[i] + delta; + } + } + } + return; + } + // walk the contour cyclically, interpolating the untouched run between each touched pair + int t1 = firstTouched; + int seen = 0; + for (int step = 1; step <= end - start + 1 && seen < touchedCount; step++) + { + int i = start + (firstTouched - start + step) % (end - start + 1); + if (touched[i]) + { + int u = t1 + 1 > end ? start : t1 + 1; + while (u != i) + { + interpolatePoint(cur, org, t1, i, u); + u = u + 1 > end ? start : u + 1; + } + t1 = i; + seen++; + } + } + } + + private static void interpolatePoint(int[] cur, int[] org, int t1, int t2, int u) + { + int orgLo; + int orgHi; + int curLo; + int curHi; + if (org[t1] <= org[t2]) + { + orgLo = org[t1]; + curLo = cur[t1]; + orgHi = org[t2]; + curHi = cur[t2]; + } + else + { + orgLo = org[t2]; + curLo = cur[t2]; + orgHi = org[t1]; + curHi = cur[t1]; + } + if (org[u] <= orgLo) + { + cur[u] = org[u] + (curLo - orgLo); + } + else if (org[u] >= orgHi) + { + cur[u] = org[u] + (curHi - orgHi); + } + else if (orgHi == orgLo) + { + cur[u] = org[u] + (curLo - orgLo); + } + else + { + cur[u] = curLo + Fixed.mulDiv(org[u] - orgLo, curHi - curLo, orgHi - orgLo); + } + } + + private void doIp(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + Zone z0 = ctx.getZone(gs.getZp0()); + Zone z1 = ctx.getZone(gs.getZp1()); + Zone z2 = ctx.getZone(gs.getZp2()); + int rp1 = gs.getRp1(); + int rp2 = gs.getRp2(); + // Measure the original positions in unscaled font units (FreeType's orus) so the interpolation + // ratio keeps full precision; the scaled F26Dot6 originals round each coordinate and can shift + // an interpolated point by a unit, which a later rounding opcode then amplifies to a whole pixel. + // Exception: twilight-zone points have no font-unit source, so their unscaled coordinates are + // (0,0); using them would collapse every original distance to zero. When any zone here is the + // twilight zone, FreeType measures the scaled originals instead, so we do the same. + boolean twilight = gs.getZp0() == 0 || gs.getZp1() == 0 || gs.getZp2() == 0; + int curRp1 = ctx.project(z0.getCurrentX()[rp1], z0.getCurrentY()[rp1]); + int orgRp1 = twilight ? ctx.dualProject(z0.getOriginalX()[rp1], z0.getOriginalY()[rp1]) + : ctx.dualProject(z0.getUnscaledX()[rp1], z0.getUnscaledY()[rp1]); + int curRp2 = ctx.project(z1.getCurrentX()[rp2], z1.getCurrentY()[rp2]); + int orgRp2 = twilight ? ctx.dualProject(z1.getOriginalX()[rp2], z1.getOriginalY()[rp2]) + : ctx.dualProject(z1.getUnscaledX()[rp2], z1.getUnscaledY()[rp2]); + int orgRange = orgRp2 - orgRp1; + int curRange = curRp2 - curRp1; + forEachLoopPoint(ctx, point -> + { + int orgP = twilight ? ctx.dualProject(z2.getOriginalX()[point], z2.getOriginalY()[point]) + : ctx.dualProject(z2.getUnscaledX()[point], z2.getUnscaledY()[point]); + int curP = ctx.project(z2.getCurrentX()[point], z2.getCurrentY()[point]); + int newP; + if (orgRange == 0) + { + newP = curRp1 + (orgP - orgRp1); + } + else + { + newP = curRp1 + Fixed.mulDiv(orgP - orgRp1, curRange, orgRange); + } + ctx.movePoint(z2, point, newP - curP); + }); + } + + // --- measurement ----------------------------------------------------- + + private void installMeasureOps() + { + dispatch[0x46] = ctx -> doGc(ctx, false); // GC[0] current + dispatch[0x47] = ctx -> doGc(ctx, true); // GC[1] original + dispatch[0x48] = this::doScfs; // SCFS + dispatch[0x49] = ctx -> doMd(ctx, false); // MD[0] grid-fitted + dispatch[0x4A] = ctx -> doMd(ctx, true); // MD[1] original + } + + private void doGc(ExecutionContext ctx, boolean original) + { + GraphicsState gs = ctx.getGraphicsState(); + Zone zone = ctx.getZone(gs.getZp2()); + int point = ctx.pop(); + if (original) + { + ctx.push(ctx.dualProject(zone.getOriginalX()[point], zone.getOriginalY()[point])); + } + else + { + ctx.push(ctx.project(zone.getCurrentX()[point], zone.getCurrentY()[point])); + } + } + + private void doScfs(ExecutionContext ctx) + { + GraphicsState gs = ctx.getGraphicsState(); + int value = ctx.pop(); + int point = ctx.pop(); + Zone zone = ctx.getZone(gs.getZp2()); + int cur = ctx.project(zone.getCurrentX()[point], zone.getCurrentY()[point]); + ctx.movePoint(zone, point, value - cur); + } + + private void doMd(ExecutionContext ctx, boolean original) + { + GraphicsState gs = ctx.getGraphicsState(); + int p2 = ctx.pop(); + int p1 = ctx.pop(); + Zone zp0 = ctx.getZone(gs.getZp0()); + Zone zp1 = ctx.getZone(gs.getZp1()); + // FreeType measures project(zp0[p1] - zp1[p2]); p1 is the deeper operand, p2 the top + if (original) + { + ctx.push(ctx.dualProjectedDistance(zp0, p1, zp1, p2)); + } + else + { + ctx.push(ctx.projectedDistance(zp0, p1, zp1, p2)); + } + } + + // --- delta exceptions ------------------------------------------------ + + private void installDeltaOps() + { + dispatch[0x5D] = ctx -> doDeltaP(ctx, 0); // DELTAP1 + dispatch[0x71] = ctx -> doDeltaP(ctx, 1); // DELTAP2 + dispatch[0x72] = ctx -> doDeltaP(ctx, 2); // DELTAP3 + dispatch[0x73] = ctx -> doDeltaC(ctx, 0); // DELTAC1 + dispatch[0x74] = ctx -> doDeltaC(ctx, 1); // DELTAC2 + dispatch[0x75] = ctx -> doDeltaC(ctx, 2); // DELTAC3 + } + + private void doDeltaP(ExecutionContext ctx, int band) + { + GraphicsState gs = ctx.getGraphicsState(); + Zone zone = ctx.getZone(gs.getZp0()); + int n = ctx.pop(); + for (int i = 0; i < n; i++) + { + int point = ctx.pop(); + int arg = ctx.pop(); + if (deltaTargetPpem(gs, arg, band) == ctx.getPpem() + && deltaPointAllowed(ctx, zone, point)) + { + ctx.movePoint(zone, point, decodeDelta(arg & 0x0F, gs.getDeltaShift())); + } + } + } + + /** + * Backward-compatibility (v40 grayscale) gate for DELTAP: once IUP has run the delta is dropped, + * and before IUP it is applied only to points already touched in y (or, for composites, when the + * freedom vector has a y component). Outside backward-compatibility mode the delta always applies. + * This keeps DELTAP from nudging untouched points off their interpolated grayscale positions. + */ + private static boolean deltaPointAllowed(ExecutionContext ctx, Zone zone, int point) + { + if (!ctx.isBackwardCompatibility()) + { + return true; + } + if (ctx.isIupDone()) + { + return false; + } + boolean touchedY = point >= 0 && point < zone.getTouchedY().length + && zone.getTouchedY()[point]; + return touchedY + || (ctx.isComposite() && ctx.getGraphicsState().getFreedomVector().getY() != 0); + } + + private void doDeltaC(ExecutionContext ctx, int band) + { + GraphicsState gs = ctx.getGraphicsState(); + int[] cvt = ctx.getControlValues(); + int n = ctx.pop(); + for (int i = 0; i < n; i++) + { + int cvtIndex = ctx.pop(); + int arg = ctx.pop(); + if (deltaTargetPpem(gs, arg, band) == ctx.getPpem() && cvtIndex >= 0 + && cvtIndex < cvt.length) + { + cvt[cvtIndex] += decodeDelta(arg & 0x0F, gs.getDeltaShift()); + } + } + } + + private static int deltaTargetPpem(GraphicsState gs, int arg, int band) + { + return ((arg >> 4) & 0x0F) + gs.getDeltaBase() + band * 16; + } + + private static int decodeDelta(int steps, int deltaShift) + { + int relative = steps < 8 ? steps - 8 : steps - 7; // 0..15 -> -8..-1, 1..8 + int unit = Fixed.ONE >> deltaShift; // 1 / 2^deltaShift of a pixel + return relative * unit; + } + + // --- flip and scan-conversion ---------------------------------------- + + private void installFlipOps() + { + dispatch[0x4D] = ctx -> ctx.getGraphicsState().setAutoFlip(true); // FLIPON + dispatch[0x4E] = ctx -> ctx.getGraphicsState().setAutoFlip(false); // FLIPOFF + dispatch[0x80] = ctx -> // FLIPPT + { + boolean[] onCurve = ctx.getZone(1).getOnCurve(); + forEachLoopPoint(ctx, point -> onCurve[point] = !onCurve[point]); + }; + dispatch[0x81] = ctx -> flipRange(ctx, true); // FLIPRGON + dispatch[0x82] = ctx -> flipRange(ctx, false); // FLIPRGOFF + dispatch[0x85] = ctx -> ctx.getGraphicsState().setScanControl(ctx.pop()); // SCANCTRL + dispatch[0x8D] = ctx -> ctx.getGraphicsState().setScanType(ctx.pop()); // SCANTYPE + dispatch[0x8E] = ctx -> // INSTCTRL + { + int selector = ctx.pop(); + int value = ctx.pop(); + if (selector == 3) + { + // native-ClearType fonts use INSTCTRL(L,3) to waive backward compatibility and program + // points to the grid directly; L==4 turns the v40 movement restrictions off + ctx.setBackwardCompatibility(value != 4); + } + else + { + ctx.getGraphicsState().setInstructControl(value & selector); + } + }; + } + + private static void flipRange(ExecutionContext ctx, boolean onCurve) + { + boolean[] flags = ctx.getZone(1).getOnCurve(); + int high = ctx.pop(); + int low = ctx.pop(); + for (int i = low; i <= high && i < flags.length; i++) + { + if (i >= 0) + { + flags[i] = onCurve; + } + } + } + + // --- loop helper ----------------------------------------------------- + + @FunctionalInterface + private interface PointConsumer + { + void accept(int point); + } + + /** Processes the graphics-state loop count of points, popping one per iteration, then resets the + * loop counter to 1. */ + private static void forEachLoopPoint(ExecutionContext ctx, PointConsumer consumer) + { + int loop = ctx.getGraphicsState().getLoop(); + for (int i = 0; i < loop; i++) + { + consumer.accept(ctx.pop()); + } + ctx.getGraphicsState().setLoop(1); + } + + // --- handler helpers ------------------------------------------------- + + @FunctionalInterface + private interface IntBinaryOp + { + int apply(int a, int b); + } + + private static void binary(ExecutionContext ctx, IntBinaryOp op) + { + int b = ctx.pop(); + int a = ctx.pop(); + ctx.push(op.apply(a, b)); + } + + private static int bool(boolean value) + { + return value ? 1 : 0; + } + + private static OpHandler roundState(int state) + { + return ctx -> ctx.getGraphicsState().setRoundState(state); + } + + private static int read(int[] array, int index, String name) + { + if (index < 0 || index >= array.length) + { + throw new HintingException(name + " index out of range: " + index); + } + return array[index]; + } + + private static void write(int[] array, int index, int value, String name) + { + if (index < 0 || index >= array.length) + { + throw new HintingException(name + " index out of range: " + index); + } + array[index] = value; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/UnitVector.java b/fontbox/src/main/java/org/apache/fontbox/ttf/UnitVector.java new file mode 100644 index 00000000000..435187654b9 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/UnitVector.java @@ -0,0 +1,151 @@ +/* + * 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.fontbox.ttf; + +/** + * A 2D unit vector in F2Dot14 fixed point, used for the projection, freedom and dual-projection + * vectors of the TrueType graphics state. Kept as a mutable class (not {@code Point2D.Float}) so the + * interpreter can stay in integer math. Because they are mutable, {@link GraphicsState#copy()} + * deep-copies them, so a per-glyph clone cannot write through to the saved post-{@code prep} template. + *

+ * Named after FreeType's {@code FT_UnitVector}, and like it the unit length is a convention rather than + * an enforced invariant: {@code SPVFS} and {@code SFVFS} write whatever the font pushed on the stack, + * which a malformed font need not have normalized. + * + * @author Apache PDFBox + */ +class UnitVector +{ + private int x; + private int y; + + /** + * @param x the x component in F2Dot14 + * @param y the y component in F2Dot14 + */ + public UnitVector(int x, int y) + { + this.x = x; + this.y = y; + } + + /** + * @return the x axis unit vector (1, 0) + */ + public static UnitVector xAxis() + { + return new UnitVector(Fixed.ONE_F2DOT14, 0); + } + + /** + * @return the y axis unit vector (0, 1) + */ + public static UnitVector yAxis() + { + return new UnitVector(0, Fixed.ONE_F2DOT14); + } + + /** + * Builds a unit vector in F2Dot14 from a coordinate delta. A zero-length delta falls back to the + * x axis. (The square-root normalization is the one place the interpreter steps outside integer + * math; it only affects a direction vector, and is verified by the golden tests.) + * + * @param dx the x delta + * @param dy the y delta + * @return the normalized unit vector + */ + public static UnitVector normalize(int dx, int dy) + { + double length = Math.hypot(dx, dy); + if (length == 0) + { + return xAxis(); + } + int ux = (int) Math.round(dx / length * Fixed.ONE_F2DOT14); + int uy = (int) Math.round(dy / length * Fixed.ONE_F2DOT14); + return new UnitVector(ux, uy); + } + + /** + * @return this vector rotated 90 degrees counter-clockwise, i.e. {@code (-y, x)} + */ + public UnitVector perpendicular() + { + return new UnitVector(-y, x); + } + + /** + * @return the x component in F2Dot14 + */ + public int getX() + { + return x; + } + + /** + * @return the y component in F2Dot14 + */ + public int getY() + { + return y; + } + + /** + * @param x the x component in F2Dot14 + * @param y the y component in F2Dot14 + */ + public void set(int x, int y) + { + this.x = x; + this.y = y; + } + + /** + * @return an independent copy of this vector + */ + public UnitVector copy() + { + return new UnitVector(x, y); + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) + { + return true; + } + if (!(obj instanceof UnitVector)) + { + return false; + } + UnitVector other = (UnitVector) obj; + return x == other.x && y == other.y; + } + + @Override + public int hashCode() + { + return 31 * x + y; + } + + @Override + public String toString() + { + return "UnitVector(" + x + ", " + y + ")"; + } +} diff --git a/fontbox/src/main/java/org/apache/fontbox/ttf/Zone.java b/fontbox/src/main/java/org/apache/fontbox/ttf/Zone.java new file mode 100644 index 00000000000..60d7aea9e21 --- /dev/null +++ b/fontbox/src/main/java/org/apache/fontbox/ttf/Zone.java @@ -0,0 +1,150 @@ +/* + * 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.fontbox.ttf; + +import java.util.Arrays; + +/** + * A set of points the interpreter can manipulate - either zone 0 (the twilight zone, holding phantom + * reference points) or zone 1 (the glyph's own outline points plus its appended phantom points). Each + * point has its scaled-but-unhinted "original" position and a "current" position the bytecode moves, + * both in F26Dot6, plus per-axis touch flags used by interpolation. + * + * @author Apache PDFBox + */ +class Zone +{ + private final int[] currentX; + private final int[] currentY; + private final int[] originalX; + private final int[] originalY; + private final int[] unscaledX; + private final int[] unscaledY; + private final boolean[] touchedX; + private final boolean[] touchedY; + private final boolean[] onCurve; + private final int[] contourEnds; + + /** + * Allocates a zone of the given size. + * + * @param pointCount number of points (including any phantom points) + * @param contourCount number of contours (0 for the twilight zone) + */ + public Zone(int pointCount, int contourCount) + { + currentX = new int[pointCount]; + currentY = new int[pointCount]; + originalX = new int[pointCount]; + originalY = new int[pointCount]; + unscaledX = new int[pointCount]; + unscaledY = new int[pointCount]; + touchedX = new boolean[pointCount]; + touchedY = new boolean[pointCount]; + onCurve = new boolean[pointCount]; + contourEnds = new int[contourCount]; + } + + /** + * Zeroes every coordinate and flag. The twilight zone outlives a single program run - it is owned by + * the interpreter so values {@code prep} puts there survive into each glyph program - so it is reset + * rather than reallocated when the size changes, as FreeType does in {@code tt_size_run_prep}. + */ + public void reset() + { + Arrays.fill(currentX, 0); + Arrays.fill(currentY, 0); + Arrays.fill(originalX, 0); + Arrays.fill(originalY, 0); + Arrays.fill(unscaledX, 0); + Arrays.fill(unscaledY, 0); + Arrays.fill(touchedX, false); + Arrays.fill(touchedY, false); + Arrays.fill(onCurve, false); + Arrays.fill(contourEnds, 0); + } + + /** @return the number of points in this zone */ + public int getPointCount() + { + return currentX.length; + } + + /** @return the current (hinted) x coordinates in F26Dot6 */ + public int[] getCurrentX() + { + return currentX; + } + + /** @return the current (hinted) y coordinates in F26Dot6 */ + public int[] getCurrentY() + { + return currentY; + } + + /** @return the original (scaled, unhinted) x coordinates in F26Dot6 */ + public int[] getOriginalX() + { + return originalX; + } + + /** @return the original (scaled, unhinted) y coordinates in F26Dot6 */ + public int[] getOriginalY() + { + return originalY; + } + + /** + * @return the original unscaled x coordinates in font units. Interpolation and relative + * measurements use these for the ratio, matching FreeType's {@code orus}, because the unrounded + * font-unit values preserve precision the scaled F26Dot6 originals would lose. + */ + public int[] getUnscaledX() + { + return unscaledX; + } + + /** @return the original unscaled y coordinates in font units */ + public int[] getUnscaledY() + { + return unscaledY; + } + + /** @return per-point touch flags for the x axis */ + public boolean[] getTouchedX() + { + return touchedX; + } + + /** @return per-point touch flags for the y axis */ + public boolean[] getTouchedY() + { + return touchedY; + } + + /** @return per-point on-curve flags */ + public boolean[] getOnCurve() + { + return onCurve; + } + + /** @return the index of the last point of each contour */ + public int[] getContourEnds() + { + return contourEnds; + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/BytecodeStreamTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/BytecodeStreamTest.java new file mode 100644 index 00000000000..968463a9977 --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/BytecodeStreamTest.java @@ -0,0 +1,78 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for the {@link BytecodeStream} cursor, including bounds checking. + */ +class BytecodeStreamTest +{ + @Test + void testSequentialReads() + { + BytecodeStream s = new BytecodeStream(new byte[] { (byte) 0xB0, 0x05, (byte) 0xFF, 0x01 }); + assertTrue(s.hasNext()); + assertEquals(0xB0, s.nextByte()); + assertEquals(5, s.nextByte()); + // 0xFF01 as a signed word is negative + assertEquals((short) 0xFF01, s.nextWord()); + assertFalse(s.hasNext()); + } + + @Test + void testByteReadPastEndThrows() + { + BytecodeStream s = new BytecodeStream(new byte[] { 0x01 }); + assertEquals(1, s.nextByte()); + assertThrows(HintingException.class, s::nextByte); + } + + @Test + void testWordReadPastEndThrows() + { + // only one byte, but a word needs two + BytecodeStream s = new BytecodeStream(new byte[] { 0x01 }); + assertThrows(HintingException.class, s::nextWord); + } + + @Test + void testSeekOutOfRangeThrows() + { + BytecodeStream s = new BytecodeStream(new byte[] { 0x01, 0x02 }); + s.seek(2); // end position is valid + assertFalse(s.hasNext()); + assertThrows(HintingException.class, () -> s.seek(3)); + assertThrows(HintingException.class, () -> s.seek(-1)); + } + + @Test + void testInstructionStartTracking() + { + BytecodeStream s = new BytecodeStream(new byte[] { 0x10, 0x11, 0x12 }); + s.nextByte(); + s.markInstructionStart(); + assertEquals(1, s.instructionStart()); + assertEquals(0x11, s.nextByte()); + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/FixedTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/FixedTest.java new file mode 100644 index 00000000000..f42b533b3bc --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/FixedTest.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. + */ +package org.apache.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the {@link Fixed} fixed-point math used by the interpreter. + */ +class FixedTest +{ + @Test + void testIntRoundTrip() + { + for (int n = -1000; n <= 1000; n++) + { + assertEquals(n, Fixed.toInt(Fixed.fromInt(n)), "round-trip " + n); + } + assertEquals(64, Fixed.fromInt(1)); + assertEquals(-128, Fixed.fromInt(-2)); + } + + @Test + void testFloorCeilRound() + { + assertEquals(64, Fixed.floor(100)); // 1.5625px -> 1px + assertEquals(128, Fixed.ceil(100)); // -> 2px + assertEquals(64, Fixed.round(70)); // just above 1px rounds to 1px + assertEquals(128, Fixed.round(96)); // 1.5px rounds up to 2px + assertEquals(0, Fixed.round(31)); // just below half a pixel rounds to 0 + assertEquals(64, Fixed.round(32)); // exactly half rounds up + } + + @Test + void testMulDiv() + { + // 2.0 * 3.0 == 6.0 + assertEquals(Fixed.fromInt(6), Fixed.mul(Fixed.fromInt(2), Fixed.fromInt(3))); + // 6.0 / 2.0 == 3.0 + assertEquals(Fixed.fromInt(3), Fixed.div(Fixed.fromInt(6), Fixed.fromInt(2))); + // division by zero is defined as zero + assertEquals(0, Fixed.div(Fixed.fromInt(5), 0)); + // signed rounding + assertEquals(-Fixed.fromInt(6), Fixed.mul(Fixed.fromInt(-2), Fixed.fromInt(3))); + } + + @Test + void testMul14() + { + // multiplying by the F2Dot14 unit (1.0) is the identity + assertEquals(Fixed.fromInt(5), Fixed.mul14(Fixed.fromInt(5), Fixed.ONE_F2DOT14)); + // multiplying by 0.5 in F2Dot14 halves the value + assertEquals(Fixed.fromInt(5) / 2, Fixed.mul14(Fixed.fromInt(5), Fixed.ONE_F2DOT14 / 2)); + } + + @Test + void testScale() + { + // 1000 font units at 16 ppem with unitsPerEm 2048 == 500 subpixel units == 7.8125px + assertEquals(500, Fixed.scale(1000, 16, 2048)); + // a flat *64 would be wrong: it must depend on unitsPerEm + assertEquals(Fixed.fromInt(16), Fixed.scale(2048, 16, 2048)); // one em == ppem pixels + assertEquals(0, Fixed.scale(1234, 16, 0)); // guard against zero unitsPerEm + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/GlyphTraceTool.java b/fontbox/src/test/java/org/apache/fontbox/ttf/GlyphTraceTool.java new file mode 100644 index 00000000000..ac9f7ec947f --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/GlyphTraceTool.java @@ -0,0 +1,72 @@ +/* + * 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.fontbox.ttf; + +import java.io.InputStream; +import java.io.PrintStream; + +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Developer tool (not a real test): dumps the interpreter's per-instruction execution trace for one + * glyph, to be diffed against a FreeType {@code ttinterp} trace by {@code trace_diff.py}. Skipped + * unless the {@code trace.gid} system property is set, e.g. + * + *

+ *   mvn -pl fontbox test -Dtest=GlyphTraceTool \
+ *       -Dtrace.font=src/test/resources/ttf/LiberationSans-Regular.ttf \
+ *       -Dtrace.gid=22 -Dtrace.ppem=11 -Dtrace.out=/tmp/our-trace.txt
+ * 
+ */ +class GlyphTraceTool +{ + @Test + void dumpTrace() throws Exception + { + String gidProp = System.getProperty("trace.gid"); + Assumptions.assumeTrue(gidProp != null, "set -Dtrace.gid to dump a trace"); + + int gid = Integer.parseInt(gidProp); + int ppem = Integer.parseInt(System.getProperty("trace.ppem", "11")); + int point = Integer.parseInt(System.getProperty("trace.point", "-1")); + String fontPath = System.getProperty("trace.font", + "src/test/resources/ttf/LiberationSans-Regular.ttf"); + String outPath = System.getProperty("trace.out"); + + TrueTypeFont font; + try (InputStream is = new java.io.FileInputStream(fontPath)) + { + // isEmbedded=true tolerates subset fonts that drop the otherwise-mandatory 'post' table + font = new TTFParser(true).parse(new RandomAccessReadBuffer(is)); + } + + PrintStream out = outPath != null ? new PrintStream(outPath, "UTF-8") : System.out; + try + { + new GlyphHinter(font).traceGlyph(gid, ppem, out, point); + } + finally + { + if (outPath != null) + { + out.close(); + } + } + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/GoldenHintingTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/GoldenHintingTest.java new file mode 100644 index 00000000000..dce85c4ff6f --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/GoldenHintingTest.java @@ -0,0 +1,233 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.junit.jupiter.api.Test; + +/** + * Golden (Tier 3) test comparing the FontBox interpreter's grid-fitted glyph points against a FreeType + * reference dump (same font, glyph and ppem). The reference lives in {@code ttf/hinting/-.txt}, + * produced offline by {@code generate_golden.py}; FreeType is never a build or runtime dependency. + *

+ * The reference is generated with FreeType's grayscale target ({@code FT_LOAD_TARGET_NORMAL}), + * i.e. the v40 "minimal" subpixel interpreter with backward compatibility, because PDFBox always + * rasterizes antialiased (Java2D). That mode is the right target for appearance but is a pile of + * heuristics rather than a clean algorithm, so the assertions here are property based rather than + * byte-exact coordinate matching: + *

    + *
  • Horizontal grid-fitting matches FreeType to within 1/64 px on every coordinate. This is + * the part that matters for weight: backward compatibility suppresses x grid-fitting so stems are + * not darkened, and we reproduce it exactly.
  • + *
  • Vertical extent tracks FreeType (the glyph bounding box matches within about half a pixel), + * so the baseline/cap snap to the grid and nothing collapses. Interior y coordinates may differ by a + * fraction of a pixel because we do not replicate every grayscale backward-compatibility heuristic; + * a soft bound keeps the bulk of them close.
  • + *
+ * Coordinates are integer F26Dot6 (64 units per pixel). + */ +class GoldenHintingTest +{ + private static final int[] PPEMS = { 11, 13, 16, 24 }; + + /** + * Horizontal grid-fitting is an exact match to FreeType's grayscale (v40) output: with backward + * compatibility, x moves are suppressed so horizontal stems keep their sub-pixel position and are not + * darkened by antialiasing. Every x coordinate agrees to within 1/64 px for simple and composite + * glyphs alike. + */ + @Test + void testHorizontalGridFittingMatchesFreeType() throws IOException + { + Stats simple = compare(false); + Stats composite = compare(true); + assertTrue(simple.maxDx <= 1, simple.summary("simple")); + assertTrue(composite.maxDx <= 1, composite.summary("composite")); + } + + /** + * Vertical hinting snaps the glyph to the pixel grid without collapsing it: the hinted y bounding box + * matches FreeType within one pixel (so the baseline and cap/x-height land on grid rows, and a + * degenerate outline - the symptom of the twilight-zone IP bug - would be caught), and the majority of + * interior y coordinates stay within 1/64 px of FreeType. + */ + @Test + void testVerticalHintingTracksFreeTypeWithoutCollapse() throws IOException + { + Stats simple = compare(false); + Stats composite = compare(true); + assertTrue(simple.maxBboxYDelta <= 64, simple.summary("simple")); + assertTrue(composite.maxBboxYDelta <= 64, composite.summary("composite")); + assertTrue(simple.yWithinOnePercent() >= 50, simple.summary("simple")); + assertTrue(composite.yWithinOnePercent() >= 50, composite.summary("composite")); + } + + private Stats compare(boolean composite) throws IOException + { + TrueTypeFont font; + try (InputStream is = getClass().getResourceAsStream("/ttf/LiberationSans-Regular.ttf")) + { + font = new TTFParser().parse(new RandomAccessReadBuffer(is)); + } + GlyphHinter hinter = new GlyphHinter(font); + Stats s = new Stats(); + + for (int ppem : PPEMS) + { + List golden = loadGolden("/ttf/hinting/LiberationSans-Regular-" + ppem + ".txt"); + assertNotNull(golden); + for (GoldenGlyph g : golden) + { + boolean isComposite = font.getGlyph().getGlyph(g.gid).getNumberOfContours() < 0; + if (isComposite != composite) + { + continue; + } + int[][] points = hinter.getHintedPointsF26Dot6(g.gid, ppem); + assertNotNull(points, "no hinted points for gid " + g.gid + " at " + ppem + "ppem"); + assertTrue(points[0].length == g.x.length, + "point count mismatch for '" + g.ch + "' at " + ppem + "ppem: ours=" + + points[0].length + " freetype=" + g.x.length); + s.recordGlyph(g, ppem, points); + } + } + return s; + } + + private static final class Stats + { + private int compared; + private int yWithinOne; + private int maxDx; + private int maxDy; + private int maxBboxYDelta; + private String worstWhere = "none"; + + void recordGlyph(GoldenGlyph g, int ppem, int[][] points) + { + int ourMinY = Integer.MAX_VALUE; + int ourMaxY = Integer.MIN_VALUE; + int ftMinY = Integer.MAX_VALUE; + int ftMaxY = Integer.MIN_VALUE; + for (int i = 0; i < g.x.length; i++) + { + compared++; + maxDx = Math.max(maxDx, Math.abs(points[0][i] - g.x[i])); + int dy = Math.abs(points[1][i] - g.y[i]); + if (dy <= 1) + { + yWithinOne++; + } + if (dy > maxDy) + { + maxDy = dy; + worstWhere = "'" + g.ch + "' (gid " + g.gid + ") point " + i + " @" + ppem + + "ppem: ours=(" + points[0][i] + "," + points[1][i] + ") freetype=(" + + g.x[i] + "," + g.y[i] + ")"; + } + ourMinY = Math.min(ourMinY, points[1][i]); + ourMaxY = Math.max(ourMaxY, points[1][i]); + ftMinY = Math.min(ftMinY, g.y[i]); + ftMaxY = Math.max(ftMaxY, g.y[i]); + } + maxBboxYDelta = Math.max(maxBboxYDelta, + Math.max(Math.abs(ourMinY - ftMinY), Math.abs(ourMaxY - ftMaxY))); + } + + int yWithinOnePercent() + { + return compared == 0 ? 100 : 100 * yWithinOne / compared; + } + + String summary(String kind) + { + return kind + ": compared " + compared + " coords; maxDx=" + maxDx + "/64 maxDy=" + maxDy + + "/64 (y within 1/64 = " + yWithinOnePercent() + "%); max bbox-y delta " + + maxBboxYDelta + "/64; worst y at " + worstWhere; + } + } + + private List loadGolden(String resource) throws IOException + { + List glyphs = new ArrayList<>(); + try (InputStream is = getClass().getResourceAsStream(resource); + BufferedReader reader = new BufferedReader( + new InputStreamReader(is, StandardCharsets.UTF_8))) + { + GoldenGlyph current = null; + String line; + while ((line = reader.readLine()) != null) + { + if (line.startsWith("glyph ")) + { + String[] parts = line.split(" "); + current = new GoldenGlyph(Integer.parseInt(parts[1]), parts[2]); + glyphs.add(current); + } + else if (line.startsWith("x ")) + { + current.x = parseInts(line.substring(2)); + } + else if (line.startsWith("y ")) + { + current.y = parseInts(line.substring(2)); + } + } + } + return glyphs; + } + + private static int[] parseInts(String s) + { + if (s.isEmpty()) + { + return new int[0]; + } + String[] tokens = s.split(" "); + int[] values = new int[tokens.length]; + for (int i = 0; i < tokens.length; i++) + { + values[i] = Integer.parseInt(tokens[i]); + } + return values; + } + + private static final class GoldenGlyph + { + private final int gid; + private final String ch; + private int[] x; + private int[] y; + + GoldenGlyph(int gid, String ch) + { + this.gid = gid; + this.ch = ch; + } + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/GraphicsStateTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/GraphicsStateTest.java new file mode 100644 index 00000000000..0ab52c91347 --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/GraphicsStateTest.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. + */ +package org.apache.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link GraphicsState} defaults, deep copy and per-glyph reset: a shallow copy would let a + * glyph write through to the saved post-{@code prep} template, and resetting too much or too little + * would discard or retain state the TrueType spec is specific about. + */ +class GraphicsStateTest +{ + @Test + void testDefaults() + { + GraphicsState gs = new GraphicsState(); + assertEquals(UnitVector.xAxis(), gs.getProjectionVector()); + assertEquals(UnitVector.xAxis(), gs.getFreedomVector()); + assertEquals(GraphicsState.ROUND_TO_GRID, gs.getRoundState()); + assertEquals(1, gs.getLoop()); + assertEquals(Fixed.ONE, gs.getMinimumDistance()); + assertEquals(17 * Fixed.ONE / 16, gs.getControlValueCutIn()); + assertEquals(9, gs.getDeltaBase()); + assertEquals(3, gs.getDeltaShift()); + assertEquals(1, gs.getZp0()); + assertEquals(0, gs.getRp0()); + assertTrue(gs.isAutoFlip()); + } + + @Test + void testDeepCopyIsIndependent() + { + GraphicsState original = new GraphicsState(); + GraphicsState clone = original.copy(); + + // the vector objects must not be shared (bug #2 guard) + assertNotSame(original.getFreedomVector(), clone.getFreedomVector()); + + // mutating the clone's vector in place must not touch the original + clone.getFreedomVector().set(0, Fixed.ONE_F2DOT14); + clone.setRp0(5); + clone.setRoundState(GraphicsState.ROUND_OFF); + + assertEquals(UnitVector.xAxis(), original.getFreedomVector()); + assertEquals(0, original.getRp0()); + assertEquals(GraphicsState.ROUND_TO_GRID, original.getRoundState()); + } + + @Test + void testResetForGlyphResetsOnlySpecMandatedFields() + { + GraphicsState gs = new GraphicsState(); + + // simulate state left behind by prep / a previous glyph + gs.getFreedomVector().set(0, Fixed.ONE_F2DOT14); + gs.getProjectionVector().set(0, Fixed.ONE_F2DOT14); + gs.setRp0(3); + gs.setRp1(4); + gs.setRp2(5); + gs.setZp0(0); + gs.setLoop(7); + // prep-configured fields that must survive a per-glyph reset + gs.setRoundState(GraphicsState.ROUND_OFF); + gs.setControlValueCutIn(999); + gs.setMinimumDistance(123); + gs.setDeltaBase(42); + + gs.resetForGlyph(); + + // reset to defaults + assertEquals(UnitVector.xAxis(), gs.getFreedomVector()); + assertEquals(UnitVector.xAxis(), gs.getProjectionVector()); + assertEquals(0, gs.getRp0()); + assertEquals(0, gs.getRp1()); + assertEquals(0, gs.getRp2()); + assertEquals(1, gs.getZp0()); + assertEquals(1, gs.getLoop()); + + // preserved from prep + assertEquals(GraphicsState.ROUND_OFF, gs.getRoundState()); + assertEquals(999, gs.getControlValueCutIn()); + assertEquals(123, gs.getMinimumDistance()); + assertEquals(42, gs.getDeltaBase()); + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/HintingConcurrencyTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/HintingConcurrencyTest.java new file mode 100644 index 00000000000..f861db0f3fa --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/HintingConcurrencyTest.java @@ -0,0 +1,150 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.geom.GeneralPath; +import java.awt.geom.PathIterator; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.junit.jupiter.api.Test; + +/** + * Hammers one shared {@link TrueTypeFont} from several threads. A system-substituted font is held in + * a process-wide cache ({@code FontMapperImpl}), so this is how the renderer really uses it, and the + * interpreter it drives is a pile of mutable state - the storage area, the twilight zone, the + * post-{@code prep} template, the cached ppem - guarded only by {@link GlyphHinter}'s monitor. + */ +class HintingConcurrencyTest +{ + private static final int THREADS = 8; + private static final int ITERATIONS = 150; + private static final int[] PPEMS = { 11, 13, 16, 24 }; + private static final String GLYPHS = "HILEToxn"; + + private static TrueTypeFont parse() throws IOException + { + try (InputStream is = HintingConcurrencyTest.class + .getResourceAsStream("/ttf/LiberationSans-Regular.ttf")) + { + assertNotNull(is, "missing test font"); + return new TTFParser().parse(new RandomAccessReadBuffer(is)); + } + } + + private static double[] flatten(GeneralPath path) + { + double[] coords = new double[6]; + List out = new ArrayList<>(); + for (PathIterator it = path.getPathIterator(null); !it.isDone(); it.next()) + { + out.add((double) it.currentSegment(coords)); + for (double c : coords) + { + out.add(c); + } + } + double[] array = new double[out.size()]; + for (int i = 0; i < array.length; i++) + { + array[i] = out.get(i); + } + return array; + } + + /** + * Every thread must get exactly what a single thread would have got. Interleaving the ppems is the + * point: a ppem change re-runs the control value program and clears the storage area and twilight + * zone, so an unsynchronized hinter would let one thread wipe the state another is mid-way through + * using, and the results would drift rather than throw. + */ + @Test + void testConcurrentHintingMatchesSingleThreadedResults() throws Exception + { + TrueTypeFont reference = parse(); + int[] gids = new int[GLYPHS.length()]; + Map expected = new HashMap<>(); + for (int g = 0; g < GLYPHS.length(); g++) + { + gids[g] = reference.getUnicodeCmapLookup().getGlyphId(GLYPHS.charAt(g)); + for (int ppem : PPEMS) + { + GeneralPath path = reference.getHintedPath(gids[g], ppem); + assertNotNull(path, "expected '" + GLYPHS.charAt(g) + "' to hint at " + ppem + "ppem"); + expected.put(key(g, ppem), flatten(path)); + } + } + + // a font nothing has hinted yet, so the run also races the lazy hinter creation + TrueTypeFont shared = parse(); + Queue problems = new ConcurrentLinkedQueue<>(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(THREADS); + List> futures = new ArrayList<>(); + for (int t = 0; t < THREADS; t++) + { + // each thread walks the glyph/ppem grid from a different offset, so the threads are + // asking for different sizes at the same moment rather than moving in lockstep + final int offset = t; + futures.add(pool.submit(() -> + { + start.await(); + for (int i = 0; i < ITERATIONS; i++) + { + int g = (i + offset) % GLYPHS.length(); + int ppem = PPEMS[(i + offset) % PPEMS.length]; + double[] actual = flatten(shared.getHintedPath(gids[g], ppem)); + if (!Arrays.equals(expected.get(key(g, ppem)), actual)) + { + problems.add("'" + GLYPHS.charAt(g) + "' at " + ppem + "ppem"); + } + } + return null; + })); + } + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(120, TimeUnit.SECONDS), "hinting threads did not finish"); + for (Future future : futures) + { + future.get(); // surfaces anything thrown inside a worker + } + assertTrue(problems.isEmpty(), + () -> problems.size() + " mismatched results, first: " + problems.peek()); + } + + private static int key(int glyphIndex, int ppem) + { + return glyphIndex * 1000 + ppem; + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/HintingIntegrationTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/HintingIntegrationTest.java new file mode 100644 index 00000000000..d843d8893a5 --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/HintingIntegrationTest.java @@ -0,0 +1,196 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.geom.GeneralPath; +import java.awt.geom.PathIterator; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.junit.jupiter.api.Test; + +/** + * End-to-end tests: drive hinting through {@link TrueTypeFont#getHintedPath(int, int)} at the + * {@link GeneralPath} level. LiberationSans carries a full bytecode program; Lohit-Bengali has a cvt + * but no fpgm/prep, so it exercises the no-bytecode fallback. + */ +class HintingIntegrationTest +{ + private static TrueTypeFont parse(String resource) throws IOException + { + try (InputStream is = HintingIntegrationTest.class.getResourceAsStream(resource)) + { + assertNotNull(is, "missing test resource " + resource); + return new TTFParser().parse(new RandomAccessReadBuffer(is)); + } + } + + private static int gid(TrueTypeFont font, int codePoint) throws IOException + { + return font.getUnicodeCmapLookup().getGlyphId(codePoint); + } + + /** Flattens a path to a coordinate list so two paths can be compared point-for-point. */ + private static double[] flatten(GeneralPath path) + { + double[] coords = new double[6]; + java.util.List out = new java.util.ArrayList<>(); + for (PathIterator it = path.getPathIterator(null); !it.isDone(); it.next()) + { + int type = it.currentSegment(coords); + out.add((double) type); + for (int i = 0; i < 6; i++) + { + out.add(coords[i]); + } + } + double[] array = new double[out.size()]; + for (int i = 0; i < array.length; i++) + { + array[i] = out.get(i); + } + return array; + } + + @Test + void testGaspGate() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + int h = gid(font, 'H'); + // LiberationSans gasp: grid-fitting is off at <=8 ppem, on above it + assertNull(font.getHintedPath(h, 8), "no hinting expected at 8ppem (gasp)"); + assertNotNull(font.getHintedPath(h, 16), "hinting expected at 16ppem"); + } + + @Test + void testHintedDiffersFromRawOutline() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + int h = gid(font, 'H'); + GeneralPath hinted = font.getHintedPath(h, 16); + assertNotNull(hinted); + GeneralPath raw = font.getGlyph().getGlyph(h).getPath(); + // grid-fitting must actually change the outline (proves the pipeline is wired in) + assertFalse(Arrays.equals(flatten(hinted), flatten(raw)), + "hinted path should differ from the raw outline"); + } + + @Test + void testDeterministic() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + int h = gid(font, 'H'); + double[] first = flatten(font.getHintedPath(h, 16)); + double[] second = flatten(font.getHintedPath(h, 16)); + // re-hinting the same glyph at the same ppem is deterministic (no leaked state) + assertTrue(Arrays.equals(first, second)); + } + + /** + * Hinting one glyph must not change the next. The storage area and twilight zone are deliberately + * shared across the glyphs hinted at one size (that is how {@code prep} seeds them), so this pins + * down that nothing else leaks between glyphs - graphics state, zone contents, the cached + * ppem. A composite is in the run because it re-enters the hinter for each component. + * + *

This is a property of a well-behaved font rather than a universal law: a glyph program may + * legally write storage, and FreeType would carry that into the next glyph too. LiberationSans does + * not, so any difference here is a bug on our side. + */ + @Test + void testHintingIsIndependentOfGlyphOrder() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + int h = gid(font, 'H'); + int eacute = gid(font, 0x00E9); + assertTrue(font.getGlyph().getGlyph(eacute).getNumberOfContours() < 0, + "expected e-acute to be a composite glyph"); + + double[] before = flatten(font.getHintedPath(h, 16)); + for (char c : "oxn8".toCharArray()) + { + font.getHintedPath(gid(font, c), 16); + } + font.getHintedPath(eacute, 16); + double[] after = flatten(font.getHintedPath(h, 16)); + + assertTrue(Arrays.equals(before, after), + "hinting other glyphs must not change the result for 'H'"); + } + + /** + * Returning to a ppem must reproduce the earlier result. A ppem change re-runs the control value + * program, which clears the storage area and twilight zone, so this covers the round trip out of a + * size and back into it. + */ + @Test + void testHintingIsIndependentOfPpemOrder() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + int h = gid(font, 'H'); + + double[] before = flatten(font.getHintedPath(h, 16)); + font.getHintedPath(h, 11); + font.getHintedPath(h, 24); + double[] after = flatten(font.getHintedPath(h, 16)); + + assertTrue(Arrays.equals(before, after), + "hinting at other ppems must not change the result at 16ppem"); + } + + @Test + void testFontWithoutBytecodeFallsBack() throws IOException + { + // Lohit-Bengali has a cvt but no fpgm and no prep: there is nothing to execute + TrueTypeFont font = parse("/ttf/Lohit-Bengali.ttf"); + int gid = font.getUnicodeCmapLookup().getGlyphId(0x0985); // Bengali letter A + assertNull(font.getHintedPath(gid, 16)); + } + + @Test + void testEmptyGlyphFallsBack() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + // the space glyph has no contours; hinting must fall back rather than fail + assertNull(font.getHintedPath(gid(font, ' '), 16)); + } + + @Test + void testCommonGlyphsHintWithoutFallingBack() throws IOException + { + // a real-font smoke test: every one of these simple glyphs carries instructions and must + // grid-fit without throwing (which would silently fall back to null). This is what caught the + // MIRP stack-order bug. + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + String sample = "HILEToxn0123456789"; + for (int ppem : new int[] { 11, 13, 16, 24 }) + { + for (int i = 0; i < sample.length(); i++) + { + char c = sample.charAt(i); + assertNotNull(font.getHintedPath(gid(font, c), ppem), + "expected '" + c + "' to hint at " + ppem + "ppem"); + } + } + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/HintingTablesTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/HintingTablesTest.java new file mode 100644 index 00000000000..c3cad84ceea --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/HintingTablesTest.java @@ -0,0 +1,125 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; + +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.junit.jupiter.api.Test; + +/** + * Tests parsing of the TrueType hinting tables ('cvt ', 'fpgm', 'prep', 'gasp'). + * + * LiberationSans-Regular was built as a hand-hinted, metric-compatible Arial replacement, so it + * carries a full bytecode hinting program and all four tables. The expected values below were taken + * from its on-disk table directory. + */ +class HintingTablesTest +{ + private static TrueTypeFont parse(String resource) throws IOException + { + try (InputStream is = HintingTablesTest.class.getResourceAsStream(resource)) + { + assertNotNull(is, "missing test resource " + resource); + return new TTFParser().parse(new RandomAccessReadBuffer(is)); + } + } + + @Test + void testControlValueTable() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + ControlValueTable cvt = font.getControlValues(); + assertNotNull(cvt); + // 648-byte table / 2 bytes per FWord + assertEquals(324, cvt.getValueCount()); + assertEquals(324, cvt.getValues().length); + // CVT entries are signed FWords (raw font units, not yet scaled to ppem) + assertEquals(1484, cvt.getValues()[0]); + } + + @Test + void testFontProgramTable() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + FontProgramTable fpgm = font.getFontProgram(); + assertNotNull(fpgm); + assertEquals(1972, fpgm.getProgram().length); + } + + @Test + void testControlValueProgramTable() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + ControlValueProgramTable prep = font.getControlValueProgram(); + assertNotNull(prep); + assertEquals(835, prep.getProgram().length); + } + + @Test + void testGaspTable() throws IOException + { + TrueTypeFont font = parse("/ttf/LiberationSans-Regular.ttf"); + GaspTable gasp = font.getGasp(); + assertNotNull(gasp); + assertEquals(0, gasp.getVersion()); + + // three ranges: (<=8: DOGRAY), (<=17: GRIDFIT), (<=65535: GRIDFIT|DOGRAY) + assertArrayEquals(new int[] { 8, 17, 65535 }, gasp.getRangeMaxPPEM()); + assertArrayEquals(new int[] { GaspTable.GASP_DOGRAY, GaspTable.GASP_GRIDFIT, + GaspTable.GASP_GRIDFIT | GaspTable.GASP_DOGRAY }, gasp.getRangeFlags()); + + // ppem -> flags lookup, including range boundaries + assertEquals(GaspTable.GASP_DOGRAY, gasp.getFlags(8)); + assertEquals(GaspTable.GASP_GRIDFIT, gasp.getFlags(9)); + assertEquals(GaspTable.GASP_GRIDFIT, gasp.getFlags(17)); + assertEquals(GaspTable.GASP_GRIDFIT | GaspTable.GASP_DOGRAY, gasp.getFlags(18)); + assertEquals(GaspTable.GASP_GRIDFIT | GaspTable.GASP_DOGRAY, gasp.getFlags(2000)); + + // grid-fitting is off at 8 ppem and below, on above it + assertFalse(gasp.isGridFit(8)); + assertTrue(gasp.isGridFit(9)); + assertTrue(gasp.isGridFit(16)); + } + + @Test + void testAbsentTablesReturnNull() throws IOException + { + // None of the bundled fonts lacks all four tables, but the absent-table path is covered: + // JosefinSans-Italic has no 'cvt '/'fpgm' (but does have 'prep'/'gasp')... + TrueTypeFont josefin = parse("/ttf/JosefinSans-Italic.ttf"); + assertNull(josefin.getControlValues()); + assertNull(josefin.getFontProgram()); + assertNotNull(josefin.getControlValueProgram()); + assertNotNull(josefin.getGasp()); + + // ...and Lohit-Tamil has no 'gasp'. Absent accessors must return null, not throw. + TrueTypeFont lohit = parse("/ttf/Lohit-Tamil.ttf"); + assertNull(lohit.getGasp()); + assertNotNull(lohit.getControlValues()); + assertNotNull(lohit.getFontProgram()); + assertNotNull(lohit.getControlValueProgram()); + } +} \ No newline at end of file diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/PointOpsTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/PointOpsTest.java new file mode 100644 index 00000000000..9c06b891b6b --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/PointOpsTest.java @@ -0,0 +1,343 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the point-moving opcodes: each builds a glyph zone, runs a short program + * through the interpreter, and asserts the resulting coordinates. Coordinates are in F26Dot6 (64 per + * pixel). Byte-exact agreement with FreeType is proven by {@link GoldenHintingTest}; these check the + * per-opcode logic. + */ +class PointOpsTest +{ + private static TrueTypeInterpreter interpreter() + { + return new TrueTypeInterpreter(256, 16, 16, 2048); + } + + /** Builds a single-contour glyph zone with the given x coordinates (original == current). */ + private static Zone lineZone(int... xs) + { + Zone zone = new Zone(xs.length, 1); + for (int i = 0; i < xs.length; i++) + { + zone.getOriginalX()[i] = xs[i]; + zone.getCurrentX()[i] = xs[i]; + // these synthetic tests work directly in device units, so the unscaled originals (used by + // IP for its interpolation ratio) mirror the scaled ones + zone.getUnscaledX()[i] = xs[i]; + } + zone.getContourEnds()[0] = xs.length - 1; + return zone; + } + + private static ExecutionContext context(TrueTypeInterpreter interp, Zone glyph) + { + ExecutionContext ctx = interp.newContext(new GraphicsState()); + ctx.setPpem(16); + ctx.setGlyphZone(glyph); + return ctx; + } + + @Test + void testProjectionAndMove() + { + ExecutionContext ctx = + new ExecutionContext(null, new GraphicsState(), 16, new int[0], null, new Zone(0, 0)); + Zone zone = lineZone(100, 0); + // default projection/freedom is the x axis: project returns the x coordinate + assertEquals(100, ctx.project(zone.getCurrentX()[0], zone.getCurrentY()[0])); + ctx.movePoint(zone, 0, 28); // move +28 along x + assertEquals(128, zone.getCurrentX()[0]); + assertTrue(zone.getTouchedX()[0]); + assertFalse(zone.getTouchedY()[0]); + } + + @Test + void testMdapRoundsToGrid() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(100); // 1.5625px + ExecutionContext ctx = context(interp, zone); + // PUSHB[0] 0 ; MDAP[1] (round) + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB0, 0, 0x2F })); + assertEquals(128, zone.getCurrentX()[0]); // rounded to 2px + assertTrue(zone.getTouchedX()[0]); + } + + @Test + void testMdrpRelativeToRp0() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 100); // rp0 = point 0 at x=0, point 1 at 1.5625px + ExecutionContext ctx = context(interp, zone); + // PUSHB[0] 1 ; MDRP[round] (0xC4, round bit = 0x04) - grid-rounded distance from rp0 + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB0, 1, (byte) 0xC4 })); + assertEquals(128, zone.getCurrentX()[1]); // distance 100 rounds to 128 + } + + @Test + void testMdrpRoundAndMinimumDistanceFlagsAreDistinct() + { + // guards the flag encoding: round is bit 0x04, minimum-distance is bit 0x08 (they were once + // swapped). A small original distance (30) below the minimum (64): + TrueTypeInterpreter interp = interpreter(); + // round only (0xC4): 30 -> round(30) = 0, no minimum clamp + Zone roundZone = lineZone(0, 30); + interp.run(context(interp, roundZone), new BytecodeStream(new byte[] { (byte) 0xB0, 1, (byte) 0xC4 })); + assertEquals(0, roundZone.getCurrentX()[1]); + + // minimum-distance only (0xC8): no rounding, but clamp the distance up to the minimum (64) + Zone minZone = lineZone(0, 30); + interp.run(context(interp, minZone), new BytecodeStream(new byte[] { (byte) 0xB0, 1, (byte) 0xC8 })); + assertEquals(64, minZone.getCurrentX()[1]); + } + + @Test + void testMirpUsesControlValue() + { + TrueTypeInterpreter interp = interpreter(); + // raw cvt 256 at 16ppem / 2048 upem scales to 128 (2px) + interp.setControlValues(new int[] { 256 }); + interp.setPpem(16, 16); + Zone zone = lineZone(0, 100); + ExecutionContext ctx = interp.newContext(new GraphicsState()); + ctx.setPpem(16); + ctx.setGlyphZone(zone); + // PUSHB[1] 1 0 (point=1 pushed first, cvtIndex=0 on top) ; MIRP[round] (0xE4, round = 0x04) + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB1, 1, 0, (byte) 0xE4 })); + assertEquals(128, zone.getCurrentX()[1]); + } + + @Test + void testMsirpSetsExactDistance() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 100); + ExecutionContext ctx = context(interp, zone); + // PUSHB[1] 1 64 (point=1, distance=1px) ; MSIRP[0] (0x3A) + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB1, 1, 64, 0x3A })); + assertEquals(64, zone.getCurrentX()[1]); // exactly 1px from rp0 at x=0 + } + + @Test + void testAlignRp() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 100); // rp0 at 0 + ExecutionContext ctx = context(interp, zone); + // PUSHB[0] 1 ; ALIGNRP (0x3C) - align point 1 onto rp0 + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB0, 1, 0x3C })); + assertEquals(0, zone.getCurrentX()[1]); + } + + @Test + void testIupInterpolatesUntouched() + { + TrueTypeInterpreter interp = interpreter(); + // three points on one contour; the middle one is untouched + Zone zone = lineZone(0, 50, 100); + zone.getTouchedX()[0] = true; + zone.getTouchedX()[2] = true; + zone.getCurrentX()[2] = 120; // the right anchor was hinted +20 + ExecutionContext ctx = context(interp, zone); + // IUP[1] (x axis) + interp.run(ctx, new BytecodeStream(new byte[] { 0x31 })); + // p1 interpolates proportionally: 0 + 50*(120-0)/100 = 60 + assertEquals(60, zone.getCurrentX()[1]); + // touched anchors are never moved by IUP + assertEquals(0, zone.getCurrentX()[0]); + assertEquals(120, zone.getCurrentX()[2]); + } + + @Test + void testIupShiftsWhenSingleTouchedPoint() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 50, 100); + zone.getTouchedX()[1] = true; + zone.getCurrentX()[1] = 70; // the only touched point moved +20 + ExecutionContext ctx = context(interp, zone); + interp.run(ctx, new BytecodeStream(new byte[] { 0x31 })); // IUP[1] + // with one touched point, every other point shifts by the same delta (+20) + assertEquals(20, zone.getCurrentX()[0]); + assertEquals(120, zone.getCurrentX()[2]); + } + + @Test + void testIpInterpolatesBetweenReferencePoints() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 50, 100); + // set rp1=0, rp2=2, move the anchors, then IP point 1 + zone.getCurrentX()[2] = 120; + GraphicsState gs = new GraphicsState(); + gs.setRp1(0); + gs.setRp2(2); + ExecutionContext ctx = interp.newContext(gs); + ctx.setPpem(16); + ctx.setGlyphZone(zone); + // PUSHB[0] 1 ; IP (0x39) + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB0, 1, 0x39 })); + assertEquals(60, zone.getCurrentX()[1]); + } + + @Test + void testIpInterpolatesTwilightPointsUsingScaledOriginals() + { + // Regression (PDFBOX-3293): twilight-zone points have no font-unit source, so their unscaled + // coordinates are (0,0). Measuring IP's interpolation ratio against those zeros collapses every + // interpolated point onto the reference point. A prep program that builds the x-height control + // value this way then yields 0, flattening whole glyphs onto the baseline at small ppem (e.g. + // lowercase 'm' in a gasp-less Arial subset at 7ppem). For twilight points IP must measure the + // scaled originals instead, exactly as FreeType's Ins_IP does. + TrueTypeInterpreter interp = interpreter(); + GraphicsState gs = new GraphicsState(); + gs.setZp0(0); // all three zone pointers reference the twilight zone + gs.setZp1(0); + gs.setZp2(0); + gs.setRp1(0); + gs.setRp2(2); + ExecutionContext ctx = interp.newContext(gs); + ctx.setPpem(16); + Zone twilight = ctx.getTwilightZone(); + // anchors at originals 0 and 100; p2's current is stretched to 120. The unscaled coordinates + // stay (0,0) for every point, exactly as MIAP leaves freshly placed twilight points. + int[] xs = { 0, 50, 100 }; + for (int p = 0; p < xs.length; p++) + { + twilight.getOriginalX()[p] = xs[p]; + twilight.getCurrentX()[p] = xs[p]; + } + twilight.getCurrentX()[2] = 120; + // PUSHB[0] 1 ; IP (0x39) + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB0, 1, 0x39 })); + // interpolate by the scaled originals: 0 + 50*(120-0)/100 = 60 (the unscaled zeros would give 0) + assertEquals(60, twilight.getCurrentX()[1]); + } + + @Test + void testSvtcaSetsProjectionVector() + { + TrueTypeInterpreter interp = interpreter(); + ExecutionContext ctx = interp.newContext(new GraphicsState()); + // SVTCA[0] (y axis) ; GPV + interp.run(ctx, new BytecodeStream(new byte[] { 0x00, 0x0C })); + assertEquals(Fixed.ONE_F2DOT14, ctx.peek(0)); // pv.y + assertEquals(0, ctx.peek(1)); // pv.x + } + + @Test + void testRoundOpcode() + { + // PUSHB[0] 100 ; ROUND[0] (0x68) -> 128 under default round-to-grid + ExecutionContext ctx = interpreter().executeProgram(new byte[] { (byte) 0xB0, 100, 0x68 }, 16); + assertEquals(128, ctx.peek(0)); + } + + @Test + void testMdMeasuresSignedDistance() + { + // MD measures project(zp0[p1] - zp1[p2]); p1 is the deeper operand, p2 the top (FreeType sign). + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 100); // point 0 at x=0, point 1 at x=100 + ExecutionContext ctx = context(interp, zone); + // PUSHB[1] 0 1 (p1=0 deeper, p2=1 top) ; MD[grid] (0x49) + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB1, 0, 1, 0x49 })); + assertEquals(-100, ctx.peek(0)); // x0 - x1 = -100 + } + + @Test + void testGcReadsProjectedCoordinate() + { + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(192); // 3px + ExecutionContext ctx = context(interp, zone); + // PUSHB[0] 0 ; GC[0] (0x46) current coordinate + interp.run(ctx, new BytecodeStream(new byte[] { (byte) 0xB0, 0, 0x46 })); + assertEquals(192, ctx.peek(0)); + } + + @Test + void testIsectMovesPointToLineIntersection() + { + TrueTypeInterpreter interp = interpreter(); + // p0 = the point to move; line A = horizontal y=64 (p1,p2); line B = vertical x=64 (p3,p4) + Zone zone = new Zone(5, 1); + setPoint(zone, 0, 0, 0); + setPoint(zone, 1, 0, 64); + setPoint(zone, 2, 128, 64); + setPoint(zone, 3, 64, 0); + setPoint(zone, 4, 64, 128); + ExecutionContext ctx = context(interp, zone); + // push p0,a0,a1,b0,b1 = 0 1 2 3 4 ; ISECT (0x0F) pops b1,b0,a1,a0,point + interp.run(ctx, new BytecodeStream( + new byte[] { (byte) 0xB4, 0, 1, 2, 3, 4, 0x0F })); + assertEquals(64, zone.getCurrentX()[0]); + assertEquals(64, zone.getCurrentY()[0]); + assertTrue(zone.getTouchedX()[0]); + assertTrue(zone.getTouchedY()[0]); + } + + // --- oracle-free invariant checks ------------------------------------ + + @Test + void testIupNeverMovesATouchedPoint() + { + // invariant: IUP leaves every already-touched point exactly where it is + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(0, 50, 100); + zone.getTouchedX()[1] = true; + zone.getCurrentX()[1] = 77; // a touched point at a deliberately off-grid position + ExecutionContext ctx = context(interp, zone); + interp.run(ctx, new BytecodeStream(new byte[] { 0x31 })); // IUP[x] + assertEquals(77, zone.getCurrentX()[1]); + } + + @Test + void testTouchedPointsLandOnGridUnderRoundToGrid() + { + // invariant: under an integer round state, a rounded (touched) point lands on a grid line + TrueTypeInterpreter interp = interpreter(); + Zone zone = lineZone(100, 150, 77); // off-grid positions + ExecutionContext ctx = context(interp, zone); // default round state is round-to-grid + // MDAP[1] each point (PUSHB[0] n ; MDAP[1]) + interp.run(ctx, new BytecodeStream(new byte[] { + (byte) 0xB0, 0, 0x2F, (byte) 0xB0, 1, 0x2F, (byte) 0xB0, 2, 0x2F })); + for (int i = 0; i < 3; i++) + { + assertTrue(zone.getTouchedX()[i], "point " + i + " should be touched"); + assertEquals(0, zone.getCurrentX()[i] % Fixed.ONE, "point " + i + " off the grid"); + } + } + + private static void setPoint(Zone zone, int i, int x, int y) + { + zone.getCurrentX()[i] = x; + zone.getCurrentY()[i] = y; + zone.getOriginalX()[i] = x; + zone.getOriginalY()[i] = y; + zone.getUnscaledX()[i] = x; + zone.getUnscaledY()[i] = y; + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/RoundStateTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/RoundStateTest.java new file mode 100644 index 00000000000..00c7161e616 --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/RoundStateTest.java @@ -0,0 +1,85 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Table-driven tests of the {@link GraphicsState#round(int)} state machine across all round states. + */ +class RoundStateTest +{ + private static int round(int state, int distance) + { + GraphicsState gs = new GraphicsState(); + gs.setRoundState(state); + return gs.round(distance); + } + + @Test + void testRoundToGrid() + { + assertEquals(128, round(GraphicsState.ROUND_TO_GRID, 100)); // 1.56px -> 2px + assertEquals(64, round(GraphicsState.ROUND_TO_GRID, 95)); // 1.48px -> 1px + assertEquals(128, round(GraphicsState.ROUND_TO_GRID, 96)); // 1.5px rounds up + assertEquals(-128, round(GraphicsState.ROUND_TO_GRID, -100)); + } + + @Test + void testRoundDownAndUp() + { + assertEquals(64, round(GraphicsState.ROUND_DOWN_TO_GRID, 100)); + assertEquals(64, round(GraphicsState.ROUND_DOWN_TO_GRID, 127)); + assertEquals(128, round(GraphicsState.ROUND_UP_TO_GRID, 65)); + assertEquals(64, round(GraphicsState.ROUND_UP_TO_GRID, 64)); + } + + @Test + void testRoundOff() + { + assertEquals(100, round(GraphicsState.ROUND_OFF, 100)); + assertEquals(-37, round(GraphicsState.ROUND_OFF, -37)); + } + + @Test + void testRoundToDoubleGrid() + { + // double grid snaps to multiples of half a pixel (32) + assertEquals(96, round(GraphicsState.ROUND_TO_DOUBLE_GRID, 80)); // 1.25px -> 1.5px + assertEquals(64, round(GraphicsState.ROUND_TO_DOUBLE_GRID, 70)); // 1.09px -> 1.0px + } + + @Test + void testRoundToHalfGrid() + { + // half grid snaps to (n + 0.5) pixels, i.e. 32, 96, 160, ... + assertEquals(96, round(GraphicsState.ROUND_TO_HALF_GRID, 100)); // -> 1.5px + assertEquals(32, round(GraphicsState.ROUND_TO_HALF_GRID, 50)); // -> 0.5px + } + + @Test + void testSuperRoundReducesToGrid() + { + // SROUND with period=1px, phase=0, threshold=half is equivalent to round-to-grid + GraphicsState gs = new GraphicsState(); + gs.setSuperRound(Fixed.ONE, 0x48); // 01 period=1.0, 00 phase=0, 1000 threshold=4*p/8=half + assertEquals(128, gs.round(100)); + assertEquals(64, gs.round(95)); + } +} diff --git a/fontbox/src/test/java/org/apache/fontbox/ttf/TrueTypeInterpreterTest.java b/fontbox/src/test/java/org/apache/fontbox/ttf/TrueTypeInterpreterTest.java new file mode 100644 index 00000000000..d1cbe58407c --- /dev/null +++ b/fontbox/src/test/java/org/apache/fontbox/ttf/TrueTypeInterpreterTest.java @@ -0,0 +1,352 @@ +/* + * 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.fontbox.ttf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +/** + * Program-tier (Tier 2) tests: hand-assembled bytecode fed through the dispatch loop with no font. + * These exercise the engine - dispatch, the push family, branching, function definition and calling, + * and the {@link BytecodeStream} bounds checks - independently of any glyph. + */ +class TrueTypeInterpreterTest +{ + // opcodes used to assemble test programs + private static final byte PUSHB1 = (byte) 0xB0; // PUSHB[0] - push one byte + private static final byte PUSHB2 = (byte) 0xB1; // PUSHB[1] - push two bytes + private static final byte NPUSHW = (byte) 0x41; + private static final byte ADD = 0x60; + private static final byte SUB = 0x61; + private static final byte MUL = 0x63; + private static final byte DUP = 0x20; + private static final byte SWAP = 0x23; + private static final byte DEPTH = 0x24; + private static final byte ROLL = (byte) 0x8A; + private static final byte GT = 0x52; + private static final byte IF = 0x58; + private static final byte ELSE = 0x1B; + private static final byte EIF = 0x59; + private static final byte PUSHW1 = (byte) 0xB8; // PUSHW[0] - push one signed word + private static final byte JMPR = 0x1C; + private static final byte JROT = 0x78; + private static final byte FDEF = 0x2C; + private static final byte ENDF = 0x2D; + private static final byte CALL = 0x2B; + private static final byte LOOPCALL = 0x2A; + private static final byte MPPEM = 0x4B; + private static final byte WS = 0x42; + private static final byte RS = 0x43; + private static final byte SZPS = 0x16; + private static final byte SCFS = 0x48; + private static final byte GC = 0x46; + + private static TrueTypeInterpreter interpreter() + { + return new TrueTypeInterpreter(256, 16, 0, 2048); + } + + private static int runTop(byte[] program) + { + ExecutionContext ctx = interpreter().executeProgram(program, 16); + return ctx.peek(0); + } + + @Test + void testPushAndAdd() + { + // PUSHB[1] 2 3 ; ADD -> 5 + assertEquals(5, runTop(new byte[] { PUSHB2, 2, 3, ADD })); + } + + @Test + void testNpushwSigned() + { + // NPUSHW 1 0xFFFF ; -> -1 on the stack + assertEquals(-1, runTop(new byte[] { NPUSHW, 1, (byte) 0xFF, (byte) 0xFF })); + } + + @Test + void testArithmetic() + { + // 10 - 3 == 7 + assertEquals(7, runTop(new byte[] { PUSHB2, 10, 3, SUB })); + // 64(=1.0) * 192(=3.0) == 192(=3.0) ... use F26Dot6: PUSHB 64, then need words; use small ints + // 2.0 * 3.0 in F26Dot6: push 128 and 192 via NPUSHW + assertEquals(Fixed.fromInt(6), + runTop(new byte[] { NPUSHW, 2, 0, (byte) 128, 0, (byte) 192, MUL })); + } + + @Test + void testStackOps() + { + // DUP: push 7, dup, add -> 14 + assertEquals(14, runTop(new byte[] { PUSHB1, 7, DUP, ADD })); + // SWAP then SUB: push 3,10 swap -> 10,3 ; SUB pops b=3,a=10 -> 7 + assertEquals(7, runTop(new byte[] { PUSHB2, 3, 10, SWAP, SUB })); + // DEPTH after pushing three values -> 3 + assertEquals(3, runTop(new byte[] { PUSHB2, 1, 2, PUSHB1, 9, DEPTH })); + // ROLL: 1 2 3 -> 2 3 1, top is 1 + assertEquals(1, runTop(new byte[] { PUSHB2, 1, 2, PUSHB1, 3, ROLL })); + } + + @Test + void testIfElseTrueBranch() + { + // push 1 (true) ; IF push 10 ELSE push 20 EIF -> 10 + assertEquals(10, runTop(new byte[] { PUSHB1, 1, IF, PUSHB1, 10, ELSE, PUSHB1, 20, EIF })); + } + + @Test + void testIfElseFalseBranch() + { + // push 0 (false) ; IF push 10 ELSE push 20 EIF -> 20 + assertEquals(20, runTop(new byte[] { PUSHB1, 0, IF, PUSHB1, 10, ELSE, PUSHB1, 20, EIF })); + } + + @Test + void testNestedIf() + { + // outer true, inner (5>3) true -> 99 + // PUSHB 1 ; IF [ PUSHB 5 3 ; GT ; IF PUSHB 99 ELSE PUSHB 1 EIF ] ELSE PUSHB 7 EIF + byte[] program = new byte[] { + PUSHB1, 1, IF, + PUSHB2, 5, 3, GT, IF, + PUSHB1, 99, + ELSE, + PUSHB1, 1, + EIF, + ELSE, + PUSHB1, 7, + EIF }; + assertEquals(99, runTop(program)); + } + + @Test + void testJmpr() + { + // PUSHB 3 ; JMPR (jump +3 from the JMPR opcode) skips a push, lands on PUSHB 42 + // layout: [0]PUSHB1 [1]3 [2]JMPR [3]PUSHB1 [4]7(skipped) [5]PUSHB1 [6]42 + ExecutionContext ctx = interpreter().executeProgram( + new byte[] { PUSHB1, 3, JMPR, PUSHB1, 7, PUSHB1, 42 }, 16); + assertEquals(42, ctx.peek(0)); + assertEquals(1, ctx.getStackDepth()); // the skipped push never ran + } + + @Test + void testFunctionDefAndCall() + { + // define function 5 = "double the top" (DUP ADD); call it on 21 -> 42 + TrueTypeInterpreter interp = interpreter(); + interp.setFontProgram(new byte[] { PUSHB1, 5, FDEF, DUP, ADD, ENDF }); + interp.prepareFontProgram(); + assertEquals(1, interp.getFunctions().size()); + + ExecutionContext ctx = interp.executeProgram(new byte[] { PUSHB1, 21, PUSHB1, 5, CALL }, 16); + assertEquals(42, ctx.peek(0)); + } + + @Test + void testLoopCall() + { + // function 1 = "add 1"; LOOPCALL it 3 times starting from 0 -> 3 + TrueTypeInterpreter interp = interpreter(); + interp.setFontProgram(new byte[] { PUSHB1, 1, FDEF, PUSHB1, 1, ADD, ENDF }); + interp.prepareFontProgram(); + + // stack: value=0, count=3, fn=1 ; LOOPCALL pops fn then count + ExecutionContext ctx = interp.executeProgram( + new byte[] { PUSHB1, 0, PUSHB2, 3, 1, LOOPCALL }, 16); + assertEquals(3, ctx.peek(0)); + } + + @Test + void testCallDepthLimitTrips() + { + // function 0 calls itself unconditionally -> must trip the depth cap, not StackOverflowError + TrueTypeInterpreter interp = interpreter(); + interp.setFontProgram(new byte[] { PUSHB1, 0, FDEF, PUSHB1, 0, CALL, ENDF }); + interp.prepareFontProgram(); + + HintingException ex = assertThrows(HintingException.class, + () -> interp.executeProgram(new byte[] { PUSHB1, 0, CALL }, 16)); + assertEquals(true, ex.getMessage().contains("call depth")); + } + + @Test + void testIdefDefinesOpcode() + { + // IDEF binds reserved opcode 0x83 to "push 42"; invoking 0x83 then runs that body. + // PUSHB[0] 0x83 ; IDEF ; PUSHB[0] 42 ; ENDF ; <0x83> + ExecutionContext ctx = interpreter().executeProgram( + new byte[] { PUSHB1, (byte) 0x83, (byte) 0x89, PUSHB1, 42, ENDF, (byte) 0x83 }, 16); + assertEquals(42, ctx.peek(0)); + } + + @Test + void testUndefinedFunctionThrows() + { + assertThrows(HintingException.class, + () -> interpreter().executeProgram(new byte[] { PUSHB1, 9, CALL }, 16)); + } + + @Test + void testMppemReflectsPpem() + { + ExecutionContext ctx = interpreter().executeProgram(new byte[] { MPPEM }, 19); + assertEquals(19, ctx.peek(0)); + } + + @Test + void testUnsupportedOpcodeThrows() + { + // 0x28 is a reserved/unused opcode; unimplemented opcodes must throw, not no-op + assertThrows(HintingException.class, + () -> interpreter().executeProgram(new byte[] { 0x28 }, 16)); + } + + @Test + void testControlValueScalingThroughPrep() + { + // raw cvt [2048] at 16 ppem, unitsPerEm 2048 -> scaled to 16px (1024 in F26Dot6) + TrueTypeInterpreter interp = interpreter(); + interp.setControlValues(new int[] { 2048 }); + interp.setPpem(16, 16); + // RCVT 0 -> the scaled value + ExecutionContext ctx = interp.executeProgram(new byte[] { PUSHB1, 0, 0x45 }, 16); + assertEquals(Fixed.fromInt(16), ctx.peek(0)); + } + + /** + * A backward jump is one of only two ways TrueType bytecode can loop, and this four-byte program - + * {@code PUSHW -3 ; JMPR}, which jumps back onto its own push - used to spin forever. It must now + * hit the execution budget and throw, so {@code GlyphHinter} falls back to the raw outline. + */ + @Test + void testBackwardJumpIsBounded() + { + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + assertThrows(HintingException.class, + () -> interpreter().executeProgram( + new byte[] { PUSHW1, (byte) 0xFF, (byte) 0xFD, JMPR }, 16))); + } + + /** + * A backward-jump loop that stays inside the budget must still run to completion - the bound exists + * to stop runaway programs, not legitimately loop-heavy ones. + */ + @Test + void testBackwardJumpWithinBudgetCompletes() + { + // counter = 60; loop { counter -= 1; if (counter != 0) jump back } -> 59 backward jumps + // [0]PUSHW1 60 [3]PUSHB1 1 [5]SUB [6]DUP [7]PUSHW1 -8 [10]SWAP [11]JROT + byte[] program = + { + PUSHW1, 0, 60, + PUSHB1, 1, SUB, DUP, PUSHW1, (byte) 0xFF, (byte) 0xF8, SWAP, JROT + }; + ExecutionContext ctx = assertTimeoutPreemptively(Duration.ofSeconds(5), + () -> interpreter().executeProgram(program, 16)); + assertEquals(0, ctx.peek(0)); + assertEquals(1, ctx.getStackDepth()); + } + + /** + * {@code LOOPCALL} takes its iteration count off the stack, so a crafted font can ask for billions. + * The whole loop is charged against the budget up front, so an absurd count fails before a single + * iteration runs. + */ + @Test + void testLoopCallCountIsBounded() + { + TrueTypeInterpreter interp = interpreter(); + interp.setFontProgram(new byte[] { PUSHB1, 1, FDEF, PUSHB1, 1, ADD, ENDF }); + interp.prepareFontProgram(); + + // stack: value=0, count=32767, fn=1 + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + assertThrows(HintingException.class, + () -> interp.executeProgram( + new byte[] { PUSHB1, 0, PUSHW1, 0x7F, (byte) 0xFF, PUSHB1, 1, + LOOPCALL }, 16))); + } + + /** + * The spec calls the {@code LOOPCALL} count unsigned; FreeType runs nothing at all when it is not + * positive, so a negative count must be a no-op rather than an error or an underflowing loop. + */ + @Test + void testNonPositiveLoopCallCountRunsNothing() + { + TrueTypeInterpreter interp = interpreter(); + interp.setFontProgram(new byte[] { PUSHB1, 1, FDEF, PUSHB1, 1, ADD, ENDF }); + interp.prepareFontProgram(); + + // stack: value=7, count=-1, fn=1 -> the function never runs, 7 is left untouched + ExecutionContext ctx = interp.executeProgram( + new byte[] { PUSHB1, 7, PUSHW1, (byte) 0xFF, (byte) 0xFF, PUSHB1, 1, LOOPCALL }, 16); + assertEquals(7, ctx.peek(0)); + assertEquals(1, ctx.getStackDepth()); + } + + /** + * A font may compute values into the storage area in {@code prep} and read them back from every + * glyph program, so storage belongs to the size and not to one program run. Building a fresh + * storage array per run made {@code RS} read zeros - silently wrong outlines rather than a failure. + */ + @Test + void testStoragePersistsFromPrepIntoGlyphProgram() + { + TrueTypeInterpreter interp = interpreter(); + interp.setControlValueProgram(new byte[] { PUSHB2, 5, 42, WS }); // storage[5] = 42 + interp.setPpem(16, 16); + + ExecutionContext ctx = interp.executeProgram(new byte[] { PUSHB1, 5, RS }, 16); + assertEquals(42, ctx.peek(0)); + } + + /** The twilight zone belongs to the size for the same reason: {@code prep} seeds points there. */ + @Test + void testTwilightPointsPersistFromPrep() + { + TrueTypeInterpreter interp = new TrueTypeInterpreter(256, 16, 4, 2048); + // prep: aim every zone pointer at the twilight zone, then set point 1's x to 128 + interp.setControlValueProgram(new byte[] { PUSHB1, 0, SZPS, PUSHB2, 1, (byte) 128, SCFS }); + interp.setPpem(16, 16); + + ExecutionContext ctx = interp.executeProgram(new byte[] { PUSHB1, 0, SZPS, PUSHB1, 1, GC }, 16); + assertEquals(128, ctx.peek(0)); + } + + /** A new size starts clean, as FreeType clears both in {@code tt_size_run_prep}. */ + @Test + void testStorageIsClearedOnPpemChange() + { + TrueTypeInterpreter interp = interpreter(); + interp.setPpem(16, 16); + interp.executeProgram(new byte[] { PUSHB2, 5, 42, WS }, 16); + assertEquals(42, interp.executeProgram(new byte[] { PUSHB1, 5, RS }, 16).peek(0)); + + interp.setPpem(24, 24); + assertEquals(0, interp.executeProgram(new byte[] { PUSHB1, 5, RS }, 24).peek(0)); + } +} diff --git a/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-11.txt b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-11.txt new file mode 100644 index 00000000000..cf92904ba8b --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-11.txt @@ -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. +font LiberationSans-Regular.ttf +ppem 11 +freetype 2.13.2 +glyph 43 H +contours 11 +x 385 385 123 123 58 58 123 123 385 385 451 451 +y 0 265 265 0 0 512 512 320 320 512 512 0 +glyph 44 I +contours 3 +x 65 65 131 131 +y 0 512 512 0 +glyph 47 L +contours 5 +x 58 58 123 123 368 368 +y 0 512 512 54 54 0 +glyph 40 E +contours 11 +x 58 58 425 425 123 123 405 405 123 123 439 439 +y 0 512 512 458 458 320 320 267 267 54 54 0 +glyph 55 T +contours 7 +x 248 248 182 182 16 16 414 414 +y 458 0 0 458 458 512 512 458 +glyph 75 h +contours 24 +x 109 129 185 228 289 346 346 346 284 284 284 270 237 207 164 111 111 111 49 49 111 111 111 108 108 +y 313 350 384 384 384 323 251 0 0 239 279 318 336 336 336 275 223 0 0 512 512 381 360 316 313 +glyph 82 o +contours 10,22 +x 362 362 276 194 113 30 30 30 197 282 362 297 297 251 198 143 95 95 95 143 194 249 297 +y 192 95 0 0 0 99 192 384 384 384 291 192 269 338 338 338 267 192 119 46 46 46 117 +glyph 91 x +contours 11 +x 275 175 75 8 140 14 83 175 267 337 210 344 +y 0 158 0 0 197 384 384 235 384 384 198 0 +glyph 81 n +contours 26 +x 284 284 284 270 238 207 163 111 111 111 49 49 49 47 105 106 106 107 108 109 130 186 228 289 346 346 346 +y 0 239 277 318 336 336 336 274 219 0 0 302 369 384 384 382 365 343 313 313 352 384 384 384 322 251 0 +glyph 19 0 +contours 11,23 +x 364 364 279 195 112 28 28 28 109 197 283 364 301 301 253 197 140 90 90 90 141 195 250 301 +y 256 131 0 0 0 131 256 384 512 512 512 383 256 365 462 462 462 366 256 150 51 51 51 152 +glyph 20 1 +contours 10 +x 54 54 177 177 68 68 182 239 239 357 357 +y 0 53 53 453 384 443 512 512 53 53 0 +glyph 21 2 +contours 30 +x 35 35 53 103 159 214 258 285 285 285 238 196 157 105 101 38 45 130 197 270 349 349 349 323 272 200 160 113 103 356 356 +y 0 44 86 150 202 247 291 340 371 414 461 461 461 418 379 385 443 512 512 512 440 373 344 286 227 166 132 78 53 53 0 +glyph 22 3 +contours 40 +x 361 361 276 197 123 35 27 91 103 196 243 296 296 296 235 178 143 143 177 228 284 284 284 238 193 152 101 97 35 42 127 193 266 347 347 347 295 246 246 300 361 +y 162 85 0 0 0 66 131 137 51 51 51 107 163 212 266 266 266 320 320 320 357 390 423 461 461 461 419 381 386 445 512 512 512 452 398 356 304 295 293 287 216 +glyph 23 4 +contours 10,18 +x 303 303 244 244 16 16 238 303 303 371 371 244 244 225 221 97 78 73 244 +y 143 0 0 143 143 190 512 512 192 192 143 443 441 409 403 224 199 192 192 +glyph 24 5 +contours 28 +x 362 362 271 190 122 39 28 91 111 192 242 298 298 298 241 193 168 125 103 42 58 334 334 115 106 146 206 277 362 +y 167 89 0 0 0 59 116 123 51 51 51 112 165 212 269 269 269 253 234 234 512 512 459 459 289 320 320 320 235 +glyph 25 6 +contours 22,34 +x 361 361 278 205 123 36 36 36 126 209 319 347 288 270 208 155 97 97 114 175 215 282 361 298 298 246 200 156 103 103 103 159 202 247 298 +y 165 89 0 0 0 125 244 374 512 512 512 413 402 462 462 462 356 256 287 320 320 320 236 163 215 272 272 272 222 177 121 50 50 50 110 +glyph 26 7 +contours 11 +x 356 282 221 190 190 125 125 204 296 36 36 356 +y 461 341 204 71 0 0 99 317 459 459 512 512 +glyph 27 8 +contours 25,36,47 +x 361 361 276 196 119 31 31 31 85 127 127 88 42 42 42 125 195 266 349 349 349 303 263 263 309 361 285 285 195 151 105 105 105 152 195 239 285 297 297 243 195 148 95 95 95 197 248 297 +y 161 85 0 0 0 83 160 214 287 295 296 305 361 399 449 512 512 512 451 399 361 305 298 296 288 216 394 466 466 466 430 394 358 320 320 320 355 166 220 274 274 274 216 165 46 46 46 104 +glyph 28 9 +contours 23,36 +x 358 358 267 183 126 58 43 102 121 184 237 296 297 283 217 177 111 33 33 33 118 194 275 358 291 291 237 192 148 96 96 96 148 192 218 265 291 +y 266 138 0 0 0 48 101 110 50 50 50 159 261 230 192 192 192 280 352 427 512 512 512 389 336 393 462 462 462 403 352 301 241 241 241 265 308 +glyph 163 á +contours 35,48,54 +x 142 86 30 30 30 106 190 274 274 274 235 194 153 115 111 46 62 196 266 337 337 337 337 351 372 381 392 392 369 344 309 278 276 274 250 187 157 191 243 274 274 274 206 163 118 94 94 94 127 142 142 217 288 288 174 +y 0 0 65 121 184 252 254 256 272 307 337 337 337 329 320 324 384 384 384 319 258 98 70 42 42 42 45 6 0 0 0 38 78 78 35 0 46 46 82 144 178 213 211 211 191 152 119 84 46 448 456 576 576 564 448 +glyph 162 à +contours 35,48,54 +x 142 86 30 30 30 106 190 274 274 274 235 194 153 115 111 46 62 196 266 337 337 337 337 351 372 381 392 392 369 344 309 278 276 274 250 187 157 191 243 274 274 274 206 163 118 94 94 94 127 215 101 101 173 247 247 +y 0 0 65 121 184 252 254 256 272 307 337 337 337 329 320 324 384 384 384 319 258 98 70 42 42 42 45 6 0 0 0 38 78 78 35 0 46 46 82 144 178 213 211 211 191 152 119 84 46 448 564 576 576 456 448 +glyph 164 â +contours 35,48,58 +x 142 86 30 30 30 106 190 274 274 274 235 194 153 115 111 46 62 196 266 337 337 337 337 351 372 381 392 392 369 344 309 278 276 274 250 187 157 191 243 274 274 274 206 163 118 94 94 94 127 303 303 267 191 191 111 75 75 155 226 +y 0 0 65 121 184 252 254 256 272 307 337 337 337 329 320 324 384 384 384 319 258 98 70 42 42 42 45 6 0 0 0 38 78 78 35 0 46 46 82 144 178 213 211 211 191 152 119 84 46 457 448 448 523 523 448 448 457 576 576 +glyph 166 ä +contours 35,48,52,56 +x 142 86 30 30 30 106 190 274 274 274 235 194 153 115 111 46 62 196 266 337 337 337 337 351 372 381 392 392 369 344 309 278 276 274 250 187 157 191 243 274 274 274 206 163 118 94 94 94 127 232 232 288 288 96 96 153 153 +y 0 0 65 121 184 252 254 256 272 307 337 337 337 329 320 324 384 384 384 319 258 98 70 42 42 42 45 6 0 0 0 38 78 78 35 0 46 46 82 144 178 213 211 211 191 152 119 84 46 448 511 511 448 448 511 511 448 +glyph 165 ã +contours 35,48,72 +x 142 86 30 30 30 106 190 274 274 274 235 194 153 115 111 46 62 196 266 337 337 337 337 351 372 381 392 392 369 344 309 278 276 274 250 187 157 191 243 274 274 274 206 163 118 94 94 94 127 253 239 210 183 159 148 129 110 107 76 80 96 124 146 161 190 217 241 251 285 291 323 317 283 +y 0 0 65 121 184 252 254 256 272 307 337 337 337 329 320 324 384 384 384 319 258 98 70 42 42 42 45 6 0 0 0 38 78 78 35 0 46 46 82 144 178 213 211 211 191 152 119 84 46 448 448 472 501 525 525 525 485 448 448 497 547 576 576 576 552 523 499 499 499 576 576 505 448 +glyph 171 é +contours 18,25,31 +x 95 95 148 199 239 287 296 350 317 199 116 30 30 30 116 196 360 360 360 296 291 242 195 150 98 96 152 152 227 298 298 184 +y 209 131 46 46 46 92 128 109 0 0 0 98 194 286 384 384 384 216 209 256 299 338 338 338 294 256 448 456 576 576 564 448 +glyph 170 è +contours 18,25,31 +x 95 95 148 199 239 287 296 350 317 199 116 30 30 30 116 196 360 360 360 296 291 242 195 150 98 96 226 112 112 184 258 258 +y 209 131 46 46 46 92 128 109 0 0 0 98 194 286 384 384 384 216 209 256 299 338 338 338 294 256 448 564 576 576 456 448 +glyph 169 ç +contours 25,44 +x 95 95 142 189 222 266 271 333 326 249 190 112 30 30 30 112 189 246 321 331 268 263 224 188 139 95 258 258 164 144 133 133 150 163 213 213 213 167 153 148 170 207 193 226 258 +y 194 120 49 49 49 88 128 124 67 0 0 0 98 192 286 384 384 384 319 261 256 293 336 336 336 271 -88 -192 -192 -192 -191 -157 -159 -159 -159 -110 -64 -64 -64 -65 0 0 0 -2 -48 +glyph 179 ñ +contours 26,50 +x 285 285 285 271 239 208 164 112 112 112 50 50 50 48 106 107 107 108 109 110 131 187 229 290 347 347 347 253 239 210 183 159 148 129 110 107 76 80 96 124 146 161 190 217 241 251 285 291 323 317 283 +y 0 239 277 318 336 336 336 274 219 0 0 302 369 384 384 382 365 343 313 313 352 384 384 384 322 251 0 448 448 472 501 525 525 525 485 448 448 497 547 576 576 576 552 523 499 499 499 576 576 505 448 +glyph 190 ü +contours 26,30,34 +x 110 110 110 124 156 187 231 283 283 283 345 345 345 347 289 288 288 286 286 285 264 208 166 105 48 48 48 232 232 288 288 96 96 153 153 +y 384 145 107 66 48 48 48 110 165 384 384 82 15 0 0 2 19 41 71 71 32 0 0 0 62 133 384 448 511 511 448 448 511 511 448 +glyph 131 Á +contours 7,16,22 +x 401 346 125 69 1 199 274 468 235 232 223 207 145 326 264 254 244 188 188 263 334 334 227 +y 0 141 141 0 0 512 512 0 463 452 420 370 192 192 371 398 431 576 586 704 704 689 576 +glyph 139 É +contours 11,17 +x 58 58 425 425 123 123 405 405 123 123 439 439 190 190 265 336 336 229 +y 0 512 512 458 458 320 320 267 267 54 54 0 576 586 704 704 689 576 +glyph 147 Ñ +contours 13,37 +x 372 113 114 116 116 58 58 134 396 392 392 392 451 451 312 298 269 242 217 207 188 169 166 135 139 155 183 205 220 249 276 299 310 344 350 382 376 342 +y 0 441 405 344 0 0 512 512 69 141 173 512 512 0 576 576 600 629 653 653 653 613 576 576 625 675 704 704 704 680 651 627 627 627 704 704 633 576 +glyph 158 Ü +contours 19,23,27 +x 251 192 103 54 54 54 120 120 120 187 251 316 389 389 389 454 454 454 404 313 293 293 349 349 157 157 214 214 +y 0 0 45 131 191 512 512 195 125 53 53 53 128 199 512 512 197 136 47 0 576 639 639 576 576 639 639 576 diff --git a/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-13.txt b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-13.txt new file mode 100644 index 00000000000..762cae84615 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-13.txt @@ -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. +font LiberationSans-Regular.ttf +ppem 13 +freetype 2.13.2 +glyph 43 H +contours 11 +x 455 455 146 146 68 68 146 146 455 455 533 533 +y 0 319 319 0 0 640 640 384 384 640 640 0 +glyph 44 I +contours 3 +x 77 77 154 154 +y 0 640 640 0 +glyph 47 L +contours 5 +x 68 68 146 146 435 435 +y 0 640 640 63 63 0 +glyph 40 E +contours 11 +x 68 68 503 503 146 146 478 478 146 146 519 519 +y 0 640 640 577 577 384 384 321 321 63 63 0 +glyph 55 T +contours 7 +x 293 293 215 215 19 19 489 489 +y 577 0 0 577 577 640 640 577 +glyph 75 h +contours 24 +x 129 153 219 270 341 409 409 409 335 335 335 318 279 245 193 131 131 131 58 58 131 131 131 128 128 +y 365 408 448 448 448 377 293 0 0 279 326 371 392 392 392 320 260 0 0 640 640 459 431 370 365 +glyph 82 o +contours 10,22 +x 428 428 326 230 133 35 35 35 232 333 428 351 351 297 233 169 112 112 112 168 229 295 351 +y 224 111 0 0 0 115 224 448 448 448 339 224 313 394 394 394 312 224 139 54 54 54 137 +glyph 91 x +contours 11 +x 325 207 88 9 166 17 98 207 316 398 249 407 +y 0 184 0 0 230 448 448 274 448 448 231 0 +glyph 81 n +contours 26 +x 335 335 335 318 281 245 192 131 131 131 58 58 58 55 125 125 126 127 128 129 154 221 270 342 409 409 409 +y 0 279 323 371 392 392 392 320 255 0 0 352 431 448 448 446 426 400 365 365 410 448 448 448 376 293 0 +glyph 19 0 +contours 11,23 +x 430 430 329 230 132 33 33 33 129 233 334 430 356 356 299 233 165 106 106 106 166 231 296 356 +y 320 164 0 0 0 163 320 480 640 640 640 478 320 458 581 581 581 459 320 185 60 60 60 188 +glyph 20 1 +contours 10 +x 63 63 209 209 80 80 215 283 283 422 422 +y 0 62 62 571 448 517 640 640 62 62 0 +glyph 21 2 +contours 30 +x 42 42 63 122 188 253 305 337 337 337 282 233 186 125 120 45 53 154 232 319 412 412 412 381 321 236 190 134 122 421 421 +y 0 51 104 185 250 307 363 424 463 519 580 580 580 529 483 490 559 640 640 640 549 465 428 355 282 205 162 94 62 62 0 +glyph 22 3 +contours 40 +x 426 426 325 232 145 42 32 107 122 232 287 350 350 350 278 210 169 169 209 269 335 335 335 281 228 180 120 115 41 49 150 228 314 410 410 410 349 290 290 354 426 +y 195 102 0 0 0 78 155 162 61 61 61 129 197 255 321 321 321 384 384 384 436 482 527 580 580 580 530 485 491 561 640 640 640 561 490 435 367 355 353 345 260 +glyph 23 4 +contours 10,18 +x 358 358 289 289 19 19 281 358 358 438 438 289 288 267 262 115 93 87 289 +y 134 0 0 134 134 199 640 640 192 192 134 558 555 509 500 238 202 192 192 +glyph 24 5 +contours 28 +x 428 428 320 225 144 46 33 107 130 226 285 352 352 352 285 228 198 147 121 50 69 394 394 136 125 173 243 328 428 +y 200 107 0 0 0 70 136 145 60 60 60 134 199 255 324 324 324 305 282 282 640 640 578 578 348 384 384 384 282 +glyph 25 6 +contours 22,34 +x 426 426 328 241 144 42 42 42 148 247 376 410 340 319 246 184 115 115 135 207 254 333 426 352 352 291 236 185 122 122 122 188 239 292 352 +y 198 106 0 0 0 156 305 467 640 640 640 524 511 581 581 581 441 309 346 384 384 384 283 195 259 328 328 328 267 213 145 59 59 59 132 +glyph 26 7 +contours 11 +x 421 333 261 225 225 148 148 241 350 43 43 421 +y 581 429 257 90 0 0 124 399 578 578 640 640 +glyph 27 8 +contours 25,36,47 +x 427 427 326 232 140 36 36 36 100 150 150 104 50 50 50 148 230 314 412 412 412 358 311 311 366 427 336 336 230 178 124 124 124 180 230 282 336 351 351 288 230 175 112 112 112 233 292 351 +y 193 102 0 0 0 100 193 257 346 355 357 368 442 492 558 640 640 640 559 490 441 366 357 355 345 258 487 586 586 586 536 487 437 384 384 384 433 200 264 329 329 329 259 198 55 55 55 124 +glyph 28 9 +contours 23,36 +x 423 423 315 216 149 68 51 121 143 218 280 349 351 335 256 209 132 39 39 39 140 229 325 423 344 344 280 227 174 113 113 113 174 226 258 313 344 +y 332 172 0 0 0 56 119 130 59 59 59 170 273 236 192 192 192 315 416 521 640 640 640 486 392 477 581 581 581 492 417 340 250 250 250 286 351 +glyph 163 á +contours 35,48,54 +x 168 102 35 35 35 125 225 324 324 324 278 230 180 135 131 55 74 231 314 398 398 398 398 415 439 450 463 463 435 406 366 328 326 324 296 221 185 225 288 324 324 324 244 193 139 111 111 111 150 167 167 256 340 340 205 +y 0 0 65 121 184 252 254 256 282 341 392 392 392 358 320 327 448 448 448 373 301 114 82 49 49 49 52 6 0 0 0 44 91 91 41 0 55 55 87 143 173 205 204 203 186 150 121 89 55 512 520 640 640 628 512 +glyph 162 à +contours 35,48,54 +x 168 102 35 35 35 125 225 324 324 324 278 230 180 135 131 55 74 231 314 398 398 398 398 415 439 450 463 463 435 406 366 328 326 324 296 221 185 225 288 324 324 324 244 193 139 111 111 111 150 253 119 119 203 291 291 +y 0 0 65 121 184 252 254 256 282 341 392 392 392 358 320 327 448 448 448 373 301 114 82 49 49 49 52 6 0 0 0 44 91 91 41 0 55 55 87 143 173 205 204 203 186 150 121 89 55 512 628 640 640 520 512 +glyph 164 â +contours 35,48,58 +x 168 102 35 35 35 125 225 324 324 324 278 230 180 135 131 55 74 231 314 398 398 398 398 415 439 450 463 463 435 406 366 328 326 324 296 221 185 225 288 324 324 324 244 193 139 111 111 111 150 358 358 315 226 226 131 89 89 184 267 +y 0 0 65 121 184 252 254 256 282 341 392 392 392 358 320 327 448 448 448 373 301 114 82 49 49 49 52 6 0 0 0 44 91 91 41 0 55 55 87 143 173 205 204 203 186 150 121 89 55 521 512 512 587 587 512 512 521 640 640 +glyph 166 ä +contours 35,48,52,56 +x 168 102 35 35 35 125 225 324 324 324 278 230 180 135 131 55 74 231 314 398 398 398 398 415 439 450 463 463 435 406 366 328 326 324 296 221 185 225 288 324 324 324 244 193 139 111 111 111 150 273 273 340 340 113 113 180 180 +y 0 0 65 121 184 252 254 256 282 341 392 392 392 358 320 327 448 448 448 373 301 114 82 49 49 49 52 6 0 0 0 44 91 91 41 0 55 55 87 143 173 205 204 203 186 150 121 89 55 512 587 587 512 512 587 587 512 +glyph 165 ã +contours 35,48,72 +x 168 102 35 35 35 125 225 324 324 324 278 230 180 135 131 55 74 231 314 398 398 398 398 415 439 450 463 463 435 406 366 328 326 324 296 221 185 225 288 324 324 324 244 193 139 111 111 111 150 300 283 249 217 188 176 154 132 128 91 95 115 148 173 191 225 257 285 297 338 345 382 375 335 +y 0 0 65 121 184 252 254 256 282 341 392 392 392 358 320 327 448 448 448 373 301 114 82 49 49 49 52 6 0 0 0 44 91 91 41 0 55 55 87 143 173 205 204 203 186 150 121 89 55 512 512 533 558 579 579 579 544 512 512 561 611 640 640 640 619 594 573 573 573 640 640 569 512 +glyph 171 é +contours 18,25,31 +x 112 112 175 235 282 340 350 414 375 235 137 35 35 35 137 232 426 426 426 350 344 285 231 178 115 113 179 179 268 352 352 217 +y 200 130 55 55 55 96 128 109 0 0 0 114 227 334 448 448 448 210 200 256 328 394 394 394 320 256 512 520 640 640 628 512 +glyph 170 è +contours 18,25,31 +x 112 112 175 235 282 340 350 414 375 235 137 35 35 35 137 232 426 426 426 350 344 285 231 178 115 113 267 133 133 217 305 305 +y 200 130 55 55 55 96 128 109 0 0 0 114 227 334 448 448 448 210 200 256 328 394 394 394 320 256 512 628 640 640 520 512 +glyph 169 ç +contours 25,44 +x 112 112 167 223 262 314 320 394 385 294 224 132 35 35 35 133 224 291 381 392 316 310 264 222 164 112 305 305 194 170 157 157 177 192 252 252 252 198 181 175 201 245 229 267 305 +y 226 141 58 58 58 92 128 124 67 0 0 0 115 224 333 448 448 448 383 325 320 353 391 391 391 316 -88 -192 -192 -192 -190 -151 -153 -153 -153 -107 -64 -64 -64 -65 0 0 0 -2 -48 +glyph 179 ñ +contours 26,50 +x 337 337 337 320 283 247 194 133 133 133 60 60 60 57 127 127 128 129 130 131 156 223 272 344 411 411 411 300 283 249 217 188 176 154 132 128 91 95 115 148 173 191 225 257 285 297 338 345 382 375 335 +y 0 279 323 371 392 392 392 320 255 0 0 352 431 448 448 446 426 400 365 365 410 448 448 448 376 293 0 512 512 533 558 579 579 579 544 512 512 561 611 640 640 640 619 594 573 573 573 640 640 569 512 +glyph 190 ü +contours 26,30,34 +x 130 130 130 147 184 220 273 334 334 334 407 407 407 409 340 340 339 338 337 336 311 244 195 123 56 56 56 274 274 341 341 114 114 181 181 +y 448 169 125 77 56 56 56 128 193 448 448 96 17 0 0 2 22 48 83 83 38 0 0 0 72 155 448 512 587 587 512 512 587 587 512 +glyph 131 Á +contours 7,16,22 +x 474 409 148 82 2 235 323 553 278 274 264 244 171 386 312 300 289 222 222 311 395 395 269 +y 0 195 195 0 0 640 640 0 582 569 531 470 256 256 471 503 544 704 714 832 832 817 704 +glyph 139 É +contours 11,17 +x 68 68 503 503 146 146 478 478 146 146 519 519 224 224 313 397 397 271 +y 0 640 640 577 577 384 384 321 321 63 63 0 704 714 832 832 817 704 +glyph 147 Ñ +contours 13,37 +x 440 133 135 137 137 68 68 158 468 463 463 463 533 533 369 352 318 286 257 245 223 201 197 160 164 184 217 242 260 295 326 354 366 407 414 451 444 404 +y 0 556 511 434 0 0 640 640 82 173 213 640 640 0 704 704 725 750 771 771 771 736 704 704 753 803 832 832 832 811 786 765 765 765 832 832 761 704 +glyph 158 Ü +contours 19,23,27 +x 297 227 122 64 64 64 142 142 142 221 296 373 459 459 459 537 537 537 478 370 346 346 413 413 186 186 253 253 +y 0 0 56 164 238 640 640 241 154 63 63 63 157 247 640 640 246 170 59 0 704 779 779 704 704 779 779 704 diff --git a/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-16.txt b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-16.txt new file mode 100644 index 00000000000..eb1d3879721 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-16.txt @@ -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. +font LiberationSans-Regular.ttf +ppem 16 +freetype 2.13.2 +glyph 43 H +contours 11 +x 561 561 180 180 84 84 180 180 561 561 656 656 +y 0 384 384 0 0 768 768 464 464 768 768 0 +glyph 44 I +contours 3 +x 95 95 190 190 +y 0 768 768 0 +glyph 47 L +contours 5 +x 84 84 180 180 536 536 +y 0 768 768 78 78 0 +glyph 40 E +contours 11 +x 84 84 619 619 180 180 589 589 180 180 639 639 +y 0 768 768 690 690 464 464 387 387 78 78 0 +glyph 55 T +contours 7 +x 360 360 265 265 23 23 602 602 +y 690 0 0 690 690 768 768 690 +glyph 75 h +contours 24 +x 159 188 269 332 420 503 503 503 413 413 413 392 344 301 238 161 161 161 71 71 161 161 161 158 157 +y 469 524 576 576 576 485 377 0 0 360 420 479 506 506 506 414 335 0 0 768 768 571 540 474 469 +glyph 82 o +contours 10,22 +x 527 527 402 283 164 43 43 43 286 410 527 432 432 366 287 208 138 138 138 207 282 363 432 +y 289 143 0 0 0 148 289 576 576 576 436 289 404 509 509 509 402 289 178 67 67 67 174 +glyph 91 x +contours 11 +x 401 255 109 12 204 21 120 255 389 490 306 501 +y 0 236 0 0 296 576 576 352 576 576 297 0 +glyph 81 n +contours 26 +x 413 413 413 392 346 301 236 161 161 161 71 71 71 68 153 154 155 156 157 159 190 271 332 421 503 503 503 +y 0 360 417 479 506 506 506 412 329 0 0 453 554 576 576 573 548 515 469 469 527 576 576 576 483 377 0 +glyph 19 0 +contours 11,23 +x 530 530 405 284 162 40 40 40 159 287 411 530 438 438 368 287 204 131 131 131 205 285 364 438 +y 384 197 0 0 0 196 384 576 768 768 768 574 384 548 695 695 695 550 384 223 74 74 74 226 +glyph 20 1 +contours 10 +x 78 78 258 258 99 99 265 348 348 520 520 +y 0 77 77 682 576 661 768 768 77 77 0 +glyph 21 2 +contours 30 +x 52 52 78 151 232 312 376 415 415 415 347 287 229 155 148 56 66 189 286 393 507 507 507 470 396 291 234 166 151 518 518 +y 0 64 127 224 302 369 436 510 556 622 694 694 694 632 575 584 668 768 768 768 659 559 515 427 339 247 197 115 77 77 0 +glyph 22 3 +contours 40 +x 525 525 401 286 179 51 39 132 150 286 354 431 431 431 343 259 208 208 257 331 413 413 413 347 281 222 148 142 51 61 185 282 388 505 505 505 430 358 358 437 525 +y 236 123 0 0 0 97 191 200 75 75 75 157 237 307 386 386 386 464 464 464 525 579 632 694 694 694 633 578 585 671 768 768 768 673 589 524 442 428 426 416 313 +glyph 23 4 +contours 10,18 +x 441 441 356 356 24 24 347 441 441 540 540 356 355 329 323 142 115 107 356 +y 185 0 0 185 185 260 768 768 256 256 185 666 663 611 601 308 267 256 256 +glyph 24 5 +contours 28 +x 527 527 394 277 178 57 41 132 161 279 351 433 433 433 351 281 244 181 150 62 85 486 486 167 154 213 300 404 527 +y 267 143 0 0 0 67 130 138 74 74 74 176 265 342 438 438 438 415 388 388 768 768 691 691 467 512 512 512 376 +glyph 25 6 +contours 22,34 +x 525 525 404 297 178 52 52 52 183 304 464 505 419 393 303 226 142 142 167 256 313 411 525 433 433 358 291 228 151 151 151 231 294 359 433 +y 265 142 0 0 0 187 367 560 768 768 768 625 609 695 695 695 553 419 465 512 512 512 378 260 347 442 442 442 358 285 192 73 73 73 173 +glyph 26 7 +contours 11 +x 518 410 321 277 277 183 183 297 431 53 53 518 +y 695 513 308 107 0 0 149 477 691 691 768 768 +glyph 27 8 +contours 25,36,47 +x 525 525 401 285 172 45 45 45 124 185 185 128 61 61 61 182 283 387 508 508 508 441 383 383 450 525 414 414 283 220 153 153 153 222 284 348 414 432 432 354 284 215 138 138 138 287 360 432 +y 225 119 0 0 0 116 224 299 401 412 414 428 520 583 665 768 768 768 667 581 519 427 415 413 401 300 577 701 701 701 639 577 514 448 448 448 509 233 306 380 380 380 300 230 68 68 68 147 +glyph 28 9 +contours 23,36 +x 521 521 389 266 184 84 63 149 176 268 345 430 432 412 315 257 162 48 48 48 172 283 400 521 423 423 345 280 215 140 140 140 215 279 318 385 423 +y 399 207 0 0 0 70 147 161 73 73 73 219 355 310 256 256 256 396 512 632 768 768 768 584 485 580 695 695 695 596 512 427 327 327 327 367 439 +glyph 163 á +contours 35,48,54 +x 208 126 44 44 44 155 278 399 399 399 343 283 223 168 162 68 91 285 387 490 490 490 490 511 541 554 570 570 536 501 451 405 402 399 365 273 228 277 354 399 399 399 300 237 171 136 136 136 184 206 206 315 418 418 253 +y 0 0 81 152 231 315 318 320 356 437 507 507 507 479 448 455 576 576 576 479 387 145 103 61 61 61 64 8 0 0 0 57 117 117 53 0 68 68 109 179 217 257 255 254 233 188 151 111 68 640 653 832 832 814 640 +glyph 162 à +contours 35,48,54 +x 208 126 44 44 44 155 278 399 399 399 343 283 223 168 162 68 91 285 387 490 490 490 490 511 541 554 570 570 536 501 451 405 402 399 365 273 228 277 354 399 399 399 300 237 171 136 136 136 184 312 147 147 251 359 359 +y 0 0 81 152 231 315 318 320 356 437 507 507 507 479 448 455 576 576 576 479 387 145 103 61 61 61 64 8 0 0 0 57 117 117 53 0 68 68 109 179 217 257 255 254 233 188 151 111 68 640 814 832 832 653 640 +glyph 164 â +contours 35,48,58 +x 208 126 44 44 44 155 278 399 399 399 343 283 223 168 162 68 91 285 387 490 490 490 490 511 541 554 570 570 536 501 451 405 402 399 365 273 228 277 354 399 399 399 300 237 171 136 136 136 184 440 440 388 278 277 161 109 109 226 328 +y 0 0 81 152 231 315 318 320 356 437 507 507 507 479 448 455 576 576 576 479 387 145 103 61 61 61 64 8 0 0 0 57 117 117 53 0 68 68 109 179 217 257 255 254 233 188 151 111 68 653 640 640 752 752 640 640 653 832 832 +glyph 166 ä +contours 35,48,52,56 +x 208 126 44 44 44 155 278 399 399 399 343 283 223 168 162 68 91 285 387 490 490 490 490 511 541 554 570 570 536 501 451 405 402 399 365 273 228 277 354 399 399 399 300 237 171 136 136 136 184 338 338 419 419 141 141 223 223 +y 0 0 81 152 231 315 318 320 356 437 507 507 507 479 448 455 576 576 576 479 387 145 103 61 61 61 64 8 0 0 0 57 117 117 53 0 68 68 109 179 217 257 255 254 233 188 151 111 68 640 732 732 640 640 732 732 640 +glyph 165 ã +contours 35,48,72 +x 208 126 44 44 44 155 278 399 399 399 343 283 223 168 162 68 91 285 387 490 490 490 490 511 541 554 570 570 536 501 451 405 402 399 365 273 228 277 354 399 399 399 300 237 171 136 136 136 184 369 348 306 267 231 216 188 161 157 111 117 141 181 213 235 277 316 350 365 415 424 470 461 411 +y 0 0 81 152 231 315 318 320 356 437 507 507 507 479 448 455 576 576 576 479 387 145 103 61 61 61 64 8 0 0 0 57 117 117 53 0 68 68 109 179 217 257 255 254 233 188 151 111 68 640 640 656 677 693 693 693 665 640 640 689 739 768 768 768 752 731 715 715 715 768 768 697 640 +glyph 171 é +contours 18,25,31 +x 138 138 215 289 348 418 431 510 462 290 170 44 44 44 169 286 524 524 524 431 424 352 284 219 142 139 220 220 329 432 432 267 +y 251 163 68 68 68 102 128 109 0 0 0 147 292 429 576 576 576 264 251 320 419 509 509 509 408 320 640 653 832 832 814 640 +glyph 170 è +contours 18,25,31 +x 138 138 215 289 348 418 431 510 462 290 170 44 44 44 169 286 524 524 524 431 424 352 284 219 142 139 329 164 164 268 376 376 +y 251 163 68 68 68 102 128 109 0 0 0 147 292 429 576 576 576 264 251 320 419 509 509 509 408 320 640 814 832 832 653 640 +glyph 169 ç +contours 25,44 +x 138 138 206 274 322 387 394 485 475 363 277 163 44 44 44 164 276 359 468 482 390 383 326 274 202 138 376 376 239 211 194 194 218 237 310 310 310 244 222 215 248 301 282 329 376 +y 290 179 71 71 71 99 128 124 67 0 0 0 147 289 429 576 576 576 511 453 448 474 505 505 505 407 -152 -256 -256 -256 -254 -205 -208 -208 -208 -167 -128 -128 -128 -129 0 0 -64 -66 -112 +glyph 179 ñ +contours 26,50 +x 415 415 415 394 348 303 238 163 163 163 73 73 73 70 155 156 157 158 159 161 192 273 334 423 505 505 505 369 348 306 267 231 216 188 161 157 111 117 141 181 213 235 277 316 350 365 415 424 470 461 411 +y 0 360 417 479 506 506 506 412 329 0 0 453 554 576 576 573 548 515 469 469 527 576 576 576 483 377 0 640 640 656 677 693 693 693 665 640 640 689 739 768 768 768 752 731 715 715 715 768 768 697 640 +glyph 190 ü +contours 26,30,34 +x 160 160 160 181 227 272 337 412 412 412 502 502 502 505 420 420 419 417 416 415 384 302 242 153 70 70 70 339 339 420 420 142 142 224 224 +y 576 216 159 97 70 70 70 164 247 576 576 123 22 0 0 3 27 59 103 103 47 0 0 0 93 199 576 640 732 732 640 640 732 732 640 +glyph 131 Á +contours 7,16,22 +x 584 503 182 101 2 290 398 681 343 338 326 301 211 475 385 371 357 274 274 383 486 486 331 +y 0 245 245 0 0 768 768 0 696 681 637 567 320 320 568 605 652 832 842 960 960 945 832 +glyph 139 É +contours 11,17 +x 84 84 619 619 180 180 589 589 180 180 639 639 276 276 385 488 488 333 +y 0 768 768 690 690 464 464 387 387 78 78 0 832 842 960 960 945 832 +glyph 147 Ñ +contours 13,37 +x 541 164 167 169 169 84 84 195 576 570 570 570 656 656 455 434 392 353 318 302 275 248 243 197 203 227 267 299 321 363 402 436 451 501 510 556 548 498 +y 0 663 609 517 0 0 768 768 101 209 258 768 768 0 832 832 848 869 885 885 885 857 832 832 881 931 960 960 960 944 923 907 907 907 960 960 889 832 +glyph 158 Ü +contours 19,23,27 +x 366 279 150 79 79 79 175 175 175 273 366 461 566 566 566 661 661 661 588 456 427 427 508 508 230 230 312 312 +y 0 0 68 197 286 768 768 291 186 78 78 78 190 298 768 768 296 204 70 0 832 924 924 832 832 924 924 832 diff --git a/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-24.txt b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-24.txt new file mode 100644 index 00000000000..1dd286dd111 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/LiberationSans-Regular-24.txt @@ -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. +font LiberationSans-Regular.ttf +ppem 24 +freetype 2.13.2 +glyph 43 H +contours 11 +x 841 841 269 269 126 126 269 269 841 841 984 984 +y 0 520 520 0 0 1088 1088 640 640 1088 1088 0 +glyph 44 I +contours 3 +x 142 142 285 285 +y 0 1088 1088 0 +glyph 47 L +contours 5 +x 126 126 269 269 803 803 +y 0 1088 1088 117 117 0 +glyph 40 E +contours 11 +x 126 126 928 928 269 269 883 883 269 269 959 959 +y 0 1088 1088 971 971 640 640 524 524 117 117 0 +glyph 55 T +contours 7 +x 540 540 398 398 35 35 903 903 +y 971 0 0 971 971 1088 1088 971 +glyph 75 h +contours 24 +x 238 282 404 498 630 755 755 755 619 619 619 588 516 452 357 242 242 242 107 107 242 242 242 236 236 +y 677 757 832 832 832 700 544 0 0 519 605 689 728 728 728 595 482 0 0 1088 1088 818 775 684 677 +glyph 82 o +contours 10,22 +x 790 790 603 424 246 65 65 65 429 615 790 648 648 548 430 312 206 206 206 310 422 544 648 +y 417 206 0 0 0 214 417 832 832 832 630 417 582 732 732 732 579 417 259 100 100 100 254 +glyph 91 x +contours 11 +x 601 383 163 17 306 31 180 383 584 734 459 752 +y 0 341 0 0 428 832 832 508 832 832 430 0 +glyph 81 n +contours 26 +x 619 619 619 588 519 452 354 242 242 242 107 107 107 102 230 231 232 235 236 238 285 407 498 631 755 755 755 +y 0 519 599 689 728 728 728 593 474 0 0 654 800 832 832 828 791 743 677 677 762 832 832 832 698 544 0 +glyph 19 0 +contours 11,23 +x 794 794 607 425 243 60 60 60 238 430 616 794 657 657 551 430 306 197 197 197 307 427 546 657 +y 544 279 0 0 0 278 544 816 1088 1088 1088 813 544 772 978 978 978 775 544 319 110 110 110 323 +glyph 20 1 +contours 10 +x 117 117 386 386 148 148 398 522 522 779 779 +y 0 115 115 959 768 895 1088 1088 115 115 0 +glyph 21 2 +contours 30 +x 77 77 115 226 347 467 563 622 622 622 520 429 343 231 221 83 98 283 429 589 761 761 761 705 594 437 351 249 226 777 777 +y 0 95 184 320 431 525 620 723 789 878 977 977 977 883 798 811 938 1088 1088 1088 935 794 731 608 485 355 283 168 115 115 0 +glyph 22 3 +contours 40 +x 787 787 601 429 268 77 59 198 225 429 531 647 647 647 514 389 312 312 386 497 619 619 619 519 421 331 221 212 77 92 277 423 582 758 758 758 644 536 536 655 787 +y 323 169 0 0 0 145 286 299 112 112 112 220 326 419 523 523 523 640 640 640 729 808 886 977 977 977 885 802 813 942 1088 1088 1088 948 823 727 607 586 583 569 429 +glyph 23 4 +contours 10,18 +x 661 661 533 533 35 35 519 661 661 809 809 533 532 493 483 212 172 160 533 +y 277 0 0 277 277 381 1088 1088 384 384 277 936 932 862 848 454 399 384 384 +glyph 24 5 +contours 28 +x 790 790 591 415 267 86 62 198 241 418 527 650 650 650 526 421 366 271 224 92 128 728 728 251 230 318 448 605 790 +y 367 196 0 0 0 129 251 267 110 110 110 246 364 467 594 594 594 560 518 518 1088 1088 973 973 637 704 704 704 517 +glyph 25 6 +contours 22,34 +x 787 787 605 446 267 78 78 78 275 456 696 758 629 589 455 339 212 212 249 382 469 615 787 650 650 537 437 342 226 226 226 347 441 539 650 +y 364 195 0 0 0 266 519 794 1088 1088 1088 872 849 978 978 978 766 565 633 704 704 704 519 358 474 600 600 600 488 391 267 109 109 109 242 +glyph 26 7 +contours 11 +x 777 615 482 415 415 274 274 446 647 79 79 777 +y 978 722 433 151 0 0 209 672 973 973 1088 1088 +glyph 27 8 +contours 25,36,47 +x 788 788 602 428 258 67 67 67 186 278 278 192 92 92 92 273 425 580 761 761 761 661 574 574 675 788 621 621 425 330 230 230 230 333 426 521 621 647 647 530 424 322 206 206 206 429 539 647 +y 320 169 0 0 0 166 318 425 571 586 589 608 739 826 943 1088 1088 1088 946 825 738 608 591 588 571 428 817 987 987 987 901 817 730 640 640 640 723 332 435 539 539 539 427 329 101 101 101 211 +glyph 28 9 +contours 23,36 +x 782 782 583 399 276 126 94 223 263 401 518 645 648 618 473 386 243 72 72 72 258 424 600 782 635 635 518 419 322 209 209 209 322 418 476 577 635 +y 565 293 0 0 0 104 221 241 109 109 109 330 534 466 384 384 384 577 736 900 1088 1088 1088 827 700 825 978 978 978 848 736 623 491 491 491 543 639 +glyph 163 á +contours 35,48,54 +x 310 188 65 65 65 231 416 598 598 598 514 424 333 250 242 101 136 427 580 734 734 734 734 766 810 829 854 854 803 750 675 607 602 598 546 409 341 416 531 598 598 598 450 355 257 204 204 204 275 309 309 472 627 627 380 +y 0 0 130 242 369 504 509 512 554 648 729 729 729 656 576 590 832 832 832 692 560 211 152 91 91 91 97 12 0 0 0 81 168 168 76 0 101 101 169 287 350 417 414 412 376 301 240 173 101 896 913 1152 1152 1128 896 +glyph 162 à +contours 35,48,54 +x 310 188 65 65 65 231 416 598 598 598 514 424 333 250 242 101 136 427 580 734 734 734 734 766 810 829 854 854 803 750 675 607 602 598 546 409 341 416 531 598 598 598 450 355 257 204 204 204 275 468 221 221 376 539 539 +y 0 0 130 242 369 504 509 512 554 648 729 729 729 656 576 590 832 832 832 692 560 211 152 91 91 91 97 12 0 0 0 81 168 168 76 0 101 101 169 287 350 417 414 412 376 301 240 173 101 896 1128 1152 1152 913 896 +glyph 164 â +contours 35,48,58 +x 310 188 65 65 65 231 416 598 598 598 514 424 333 250 242 101 136 427 580 734 734 734 734 766 810 829 854 854 803 750 675 607 602 598 546 409 341 416 531 598 598 598 450 355 257 204 204 204 275 661 661 582 418 416 242 164 164 340 493 +y 0 0 130 242 369 504 509 512 554 648 729 729 729 656 576 590 832 832 832 692 560 211 152 91 91 91 97 12 0 0 0 81 168 168 76 0 101 101 169 287 350 417 414 412 376 301 240 173 101 914 896 896 1045 1045 896 896 914 1152 1152 +glyph 166 ä +contours 35,48,52,56 +x 310 188 65 65 65 231 416 598 598 598 514 424 333 250 242 101 136 427 580 734 734 734 734 766 810 829 854 854 803 750 675 607 602 598 546 409 341 416 531 598 598 598 450 355 257 204 204 204 275 505 505 628 628 210 210 334 334 +y 0 0 130 242 369 504 509 512 554 648 729 729 729 656 576 590 832 832 832 692 560 211 152 91 91 91 97 12 0 0 0 81 168 168 76 0 101 101 169 287 350 417 414 412 376 301 240 173 101 896 1034 1034 896 896 1034 1034 896 +glyph 165 ã +contours 35,48,72 +x 310 188 65 65 65 231 416 598 598 598 514 424 333 250 242 101 136 427 580 734 734 734 734 766 810 829 854 854 803 750 675 607 602 598 546 409 341 416 531 598 598 598 450 355 257 204 204 204 275 553 522 459 400 347 324 283 242 235 167 175 211 272 319 352 415 474 526 548 623 636 705 692 617 +y 0 0 130 242 369 504 509 512 554 648 729 729 729 656 576 590 832 832 832 692 560 211 152 91 91 91 97 12 0 0 0 81 168 168 76 0 101 101 169 287 350 417 414 412 376 301 240 173 101 896 896 921 951 976 976 976 934 896 896 969 1044 1088 1088 1088 1063 1033 1008 1008 1008 1088 1088 982 896 +glyph 171 é +contours 18,25,31 +x 207 207 323 434 521 627 646 764 691 433 253 65 65 65 253 428 786 786 786 647 636 528 427 328 214 209 330 330 493 648 648 401 +y 408 261 101 101 101 189 256 218 0 0 0 212 421 620 832 832 832 425 408 512 627 732 732 732 615 512 896 913 1152 1152 1128 896 +glyph 170 è +contours 18,25,31 +x 207 207 323 434 521 627 646 764 691 433 253 65 65 65 253 428 786 786 786 647 636 528 427 328 214 209 493 246 246 401 564 564 +y 408 261 101 101 101 189 256 218 0 0 0 212 421 620 832 832 832 425 408 512 627 732 732 732 615 512 896 1128 1152 1152 913 896 +glyph 169 ç +contours 25,44 +x 206 206 308 411 483 580 591 728 712 544 415 244 65 65 65 245 413 538 702 723 584 574 488 409 302 206 563 563 358 315 290 290 327 355 465 465 465 366 333 323 371 452 422 493 563 +y 420 260 107 107 107 180 256 247 135 0 0 0 213 417 619 832 832 832 701 587 576 645 726 726 726 586 -181 -320 -320 -320 -317 -243 -248 -248 -248 -186 -128 -128 -128 -130 0 0 -64 -66 -128 +glyph 179 ñ +contours 26,50 +x 622 622 622 591 522 455 357 245 245 245 110 110 110 105 233 234 235 238 239 241 288 410 501 634 758 758 758 553 522 459 400 347 324 283 242 235 167 175 211 272 319 352 415 474 526 548 623 636 705 692 617 +y 0 519 599 689 728 728 728 593 474 0 0 654 800 832 832 828 791 743 677 677 762 832 832 832 698 544 0 896 896 921 951 976 976 976 934 896 896 969 1044 1088 1088 1088 1063 1033 1008 1008 1008 1088 1088 982 896 +glyph 190 ü +contours 26,30,34 +x 241 241 241 272 341 408 506 618 618 618 753 753 753 757 630 629 628 626 624 622 575 453 362 229 105 105 105 507 507 630 630 212 212 336 336 +y 832 313 233 143 104 104 104 239 358 832 832 178 32 0 0 4 41 88 154 154 70 0 0 0 134 288 832 896 1034 1034 896 896 1034 1034 896 +glyph 131 Á +contours 7,16,22 +x 875 755 273 152 3 434 597 1022 514 507 489 452 317 712 576 555 534 410 410 573 728 728 496 +y 0 336 336 0 0 1088 1088 0 980 959 896 798 448 448 799 852 917 1152 1168 1344 1344 1321 1152 +glyph 139 É +contours 11,17 +x 126 126 928 928 269 269 883 883 269 269 959 959 413 413 576 731 731 499 +y 0 1088 1088 971 971 640 640 524 524 117 117 0 1152 1168 1344 1344 1321 1152 +glyph 147 Ñ +contours 13,37 +x 812 246 250 254 254 126 126 293 864 855 855 855 984 984 682 651 588 529 476 453 411 371 364 296 304 340 401 448 481 544 603 655 677 752 765 834 821 746 +y 0 931 856 726 0 0 1088 1088 151 303 371 1088 1088 0 1152 1152 1177 1207 1232 1232 1232 1190 1152 1152 1225 1300 1344 1344 1344 1319 1289 1264 1264 1264 1344 1344 1238 1152 +glyph 158 Ü +contours 19,23,27 +x 549 419 225 119 119 119 262 262 262 409 548 690 848 848 848 991 991 991 882 684 640 640 763 763 345 345 469 469 +y 0 0 96 279 405 1088 1088 416 269 116 116 116 274 426 1088 1088 419 289 100 0 1152 1290 1290 1152 1152 1290 1290 1152 diff --git a/fontbox/src/test/resources/ttf/hinting/README.md b/fontbox/src/test/resources/ttf/hinting/README.md new file mode 100644 index 00000000000..66e4aff6f66 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/README.md @@ -0,0 +1,103 @@ + + +# TrueType hinting verification tooling + +These are developer/debugging tools for the FontBox TrueType bytecode interpreter +(`org.apache.fontbox.ttf.instruction`). They use **FreeType only as an offline oracle** — FreeType is +never linked, shipped, or a build dependency. The committed data files are plain coordinate/trace +facts, not derivatives of FreeType (see `hinting_plan.md` "Oracle licensing"). + +## Files + +| File | Purpose | +|------|---------| +| `generate_golden.py` | Dumps FreeType's post-hinting outline points for the Tier-A fonts to `-.txt`. These back `GoldenHintingTest`. | +| `LiberationSans-Regular-*.txt` | The committed golden coordinate data (one file per ppem). | +| `trace_diff.py` | Aligns FreeType's per-instruction trace against the FontBox interpreter's trace and reports the first divergence (program counter, operand stack, or point coordinate). | +| `ft_point_trace.c` | FreeType single-stepper: dumps one glyph point's coordinate per instruction, for localizing *silent* point-position divergence. | +| `README.md` | This file. | + +## Golden coordinate test (CI) + +`GoldenHintingTest` compares the interpreter's grid-fitted points against the committed `.txt` dumps. +To regenerate the dumps after intentional changes: + +```sh +pip install --user freetype-py +python3 generate_golden.py +``` + +## Trace-diff harness (manual debugging) + +When a glyph's final points differ from FreeType, the trace diff localizes the cause to a single +instruction far faster than staring at coordinates. It needs a FreeType built **with tracing** (the +stock library has it compiled out): + +```sh +curl -LO https://download.savannah.gnu.org/releases/freetype/freetype-2.13.2.tar.gz +tar xzf freetype-2.13.2.tar.gz && cd freetype-2.13.2 +./configure CFLAGS="-DFT_DEBUG_LEVEL_TRACE -g -O1" --disable-static +make -j +# point freetype-py at the result (replace its bundled copy or LD_PRELOAD it): +cp objs/.libs/libfreetype.so.6.* "$(python3 -c 'import freetype,os;print(os.path.dirname(freetype.__file__))')/libfreetype.so" +``` + +Then dump the FontBox trace for a glyph and diff it: + +```sh +# 1. FontBox trace (from the fontbox module dir): +mvn -pl fontbox test -Dtest=GlyphTraceTool -Denforcer.skip=true \ + -Dtrace.gid=164 -Dtrace.ppem=11 -Dtrace.out=/tmp/our-trace.txt + +# 2. diff against FreeType (from this directory): +FT2_DEBUG=ttinterp:7 python3 trace_diff.py --gid 164 --ppem 11 --ours /tmp/our-trace.txt --stack +``` + +Both traces use the line format ` # `. The tool compares the +program counter (catches control-flow divergence) and, with `--stack`, the operand window (catches a +diverging computed value — this is how the `DIV`-rounding bug was found). If neither diverges, the +remaining difference is a *silent* point-position computation in a point-moving opcode (MDRP/MIRP/IP/…) +that never reaches the stack — compare final point positions to find it. + +The interpreter side is driven by `TrueTypeInterpreter.setTracer(ExecutionTracer)`; it is off in +normal operation. + +### Localizing a *silent* point divergence (points extension) + +When the stack matches FreeType end-to-end but the final points still differ, the divergence is a +point-moving opcode computing a slightly different displacement from an input that never reaches the +stack (e.g. a CVT value). To find which instruction, compare the point coordinate itself per +instruction. `ft_point_trace.c` is the FreeType half (build instructions are in its header comment); +it needs a **static** FreeType built with the bytecode interpreter so it can link the internal +`TT_RunIns` and single-step via the debug hook. + +```sh +# FreeType per-instruction point trace (point 8 of glyph 648): +./ft_point_trace ../LiberationSans-Regular.ttf 648 11 8 > /tmp/ft-pt.txt + +# FontBox per-instruction point trace for the same point: +mvn -pl fontbox test -Dtest=GlyphTraceTool -Denforcer.skip=true \ + -Dtrace.gid=648 -Dtrace.ppem=11 -Dtrace.point=8 -Dtrace.out=/tmp/our-pt.txt + +# diff the point column: +python3 trace_diff.py --gid 648 --ppem 11 --ours /tmp/our-pt.txt --ft /tmp/ft-pt.txt --point +``` + +The output names the exact instruction whose result first differs - e.g. it localized the +`a-circumflex` residual to a single `MIRP` (the circumflex height), where the point goes in equal +(80,513) and comes out (80,512) in FreeType versus (80,484) in FontBox. diff --git a/fontbox/src/test/resources/ttf/hinting/ft_point_trace.c b/fontbox/src/test/resources/ttf/hinting/ft_point_trace.c new file mode 100644 index 00000000000..35f167b35c6 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/ft_point_trace.c @@ -0,0 +1,109 @@ +/* + * 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. + */ + +/* + * FreeType per-instruction point tracer - the FreeType half of the "points extension" to the + * trace-diff harness. It single-steps the TrueType bytecode interpreter via the debug hook and prints, + * for each instruction, the current coordinate of one glyph point. Diffing this against the FontBox + * trace (GlyphTraceTool with -Dtrace.point) localizes a *silent* point-position divergence (one a + * point-moving opcode produces without it ever reaching the operand stack) to a single instruction. + * + * FreeType is used here only as an offline debugging oracle - never shipped or a build dependency. + * + * Build (against a static FreeType built with the bytecode interpreter; internal headers required): + * + * FT=/path/to/freetype-2.13.2 + * ./$FT/configure CFLAGS="-DFT_DEBUG_LEVEL_TRACE -DFT_DEBUG_LEVEL_DEBUG -g -O0" \ + * --enable-static --disable-shared --without-zlib --without-png --without-harfbuzz \ + * --without-brotli --without-bzip2 && make -C $FT -j + * gcc -DFT2_BUILD_LIBRARY -I$FT/include -I$FT/src ft_point_trace.c \ + * $FT/objs/.libs/libfreetype.a -lm -o ft_point_trace + * + * Run: + * ./ft_point_trace LiberationSans-Regular.ttf # lines: " op=0xNN Pn=(x,y)" + */ +#include +#include + +#include +#include FT_FREETYPE_H +#include /* FT_Set_Debug_Hook, FT_DEBUG_HOOK_TRUETYPE */ +#include "truetype/ttinterp.h" /* TT_ExecContext, TT_RunIns (internal) */ + +static int g_point = -1; + +static FT_Error +trace_hook( void* exec ) +{ + TT_ExecContext exc = (TT_ExecContext)exec; + FT_Error err = FT_Err_Ok; + + + exc->instruction_trap = 1; /* make TT_RunIns return after each instruction */ + + while ( exc->IP < exc->codeSize ) + { + long ip = exc->IP; + FT_Byte op = exc->code[ip]; + + + if ( g_point >= 0 && exc->pts.n_points > g_point ) + printf( "%06ld op=0x%02X P%d=(%ld,%ld)\n", ip, op, g_point, + (long)exc->pts.cur[g_point].x, (long)exc->pts.cur[g_point].y ); + else + printf( "%06ld op=0x%02X\n", ip, op ); + + err = TT_RunIns( exec ); + if ( err ) + break; + } + + return err; +} + + +int +main( int argc, char** argv ) +{ + FT_Library lib; + FT_Face face; + + + if ( argc < 5 ) + { + fprintf( stderr, "usage: %s \n", argv[0] ); + return 2; + } + + g_point = atoi( argv[4] ); + + if ( FT_Init_FreeType( &lib ) ) + return 1; + + FT_Set_Debug_Hook( lib, FT_DEBUG_HOOK_TRUETYPE, (FT_DebugHook_Func)trace_hook ); + + if ( FT_New_Face( lib, argv[1], 0, &face ) ) + return 1; + + FT_Set_Pixel_Sizes( face, 0, atoi( argv[3] ) ); + FT_Load_Glyph( face, atoi( argv[2] ), + FT_LOAD_NO_AUTOHINT | FT_LOAD_TARGET_MONO ); + + FT_Done_Face( face ); + FT_Done_FreeType( lib ); + return 0; +} diff --git a/fontbox/src/test/resources/ttf/hinting/generate_golden.py b/fontbox/src/test/resources/ttf/hinting/generate_golden.py new file mode 100644 index 00000000000..3289f47f6d5 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/generate_golden.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# 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. +""" +Generates TrueType hinting golden reference data from FreeType, for the Java golden tests. + +FreeType is used here ONLY as an offline verification oracle - it is never shipped, linked, or made a +build dependency. The committed JSON files are plain coordinate facts about the fonts (the F26Dot6 +points a correct bytecode interpreter must produce), not a derivative of FreeType. See +hinting_plan.md "Oracle licensing". + +Run with freetype-py installed (`pip install freetype-py`): + + python3 generate_golden.py + +It writes -.txt next to this script in a compact, dependency-free format (so the Java +test needs no JSON library). The glyphs are loaded with the monochrome hinting target so FreeType runs +the native TrueType bytecode interpreter in full-pixel (non-subpixel) mode, which is what the FontBox +interpreter implements. + +Format (one file per ppem): + + font + ppem + freetype + glyph + contours + x ... + y ... + glyph ... +""" +import os + +import freetype + +HERE = os.path.dirname(os.path.abspath(__file__)) +FONT_DIR = os.path.normpath(os.path.join(HERE, "..")) + +FONTS = ["LiberationSans-Regular.ttf"] +PPEMS = [11, 13, 16, 24] +# simple, well-hinted glyphs plus common composites (accented letters: base glyph + diacritic) +CHARS = "HILEThoxn0123456789" + "áàâäãéèçñüÁÉÑÜ" + +# native bytecode hinting (no autohinter), grayscale target => FreeType's v40 "minimal" subpixel +# interpreter with backward-compatibility (no x grid-fitting, y frozen post-IUP). This matches how +# PDFBox rasterizes (Java2D is always antialiased); see GlyphHinter / ExecutionContext.movePoint. +LOAD_FLAGS = freetype.FT_LOAD_NO_AUTOHINT | freetype.FT_LOAD_TARGET_NORMAL + +# Prepended to every generated file: these are checked into an Apache project, and the parser in +# GoldenHintingTest ignores any line that is not "glyph "/"x "/"y ", so comments cost nothing. +LICENSE_HEADER = [ + "# 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.", +] + + +def dump_font(font_name): + face = freetype.Face(os.path.join(FONT_DIR, font_name)) + version = ".".join(str(v) for v in freetype.version()) + for ppem in PPEMS: + face.set_pixel_sizes(0, ppem) + lines = list(LICENSE_HEADER) + lines += [f"font {font_name}", f"ppem {ppem}", f"freetype {version}"] + count = 0 + for ch in CHARS: + gid = face.get_char_index(ord(ch)) + if gid == 0: + continue + face.load_glyph(gid, LOAD_FLAGS) + outline = face.glyph.outline + lines.append(f"glyph {gid} {ch}") + lines.append("contours " + ",".join(str(c) for c in outline.contours)) + lines.append("x " + " ".join(str(p[0]) for p in outline.points)) + lines.append("y " + " ".join(str(p[1]) for p in outline.points)) + count += 1 + out = os.path.join(HERE, f"{os.path.splitext(font_name)[0]}-{ppem}.txt") + with open(out, "w") as fh: + fh.write("\n".join(lines) + "\n") + print(f"wrote {out} ({count} glyphs)") + + +if __name__ == "__main__": + for font in FONTS: + dump_font(font) diff --git a/fontbox/src/test/resources/ttf/hinting/trace_diff.py b/fontbox/src/test/resources/ttf/hinting/trace_diff.py new file mode 100644 index 00000000000..93a9d7a5816 --- /dev/null +++ b/fontbox/src/test/resources/ttf/hinting/trace_diff.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# 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. +""" +TrueType hinting trace-diff harness. + +Captures FreeType's per-instruction ``ttinterp`` trace for one glyph and aligns it, instruction by +instruction, against the FontBox interpreter's trace, reporting the first instruction where the +program counter, opcode, or operand stack diverges. This localizes a hinting bug to the exact +instruction far faster than comparing final outlines. + +FreeType is used here only as an offline debugging oracle - never shipped or a build dependency. + +Prerequisites +------------- +1. A FreeType built with tracing (the stock library has it compiled out). For example: + + curl -LO https://download.savannah.gnu.org/releases/freetype/freetype-2.13.2.tar.gz + tar xzf freetype-2.13.2.tar.gz && cd freetype-2.13.2 + ./configure CFLAGS="-DFT_DEBUG_LEVEL_TRACE -g -O1" --disable-static + make -j + Then point freetype-py at objs/.libs/libfreetype.so (replace its bundled copy, or LD_PRELOAD it), + and ``pip install freetype-py``. + +2. The FontBox trace for the same glyph, produced by GlyphTraceTool: + + mvn -pl fontbox test -Dtest=GlyphTraceTool -Denforcer.skip=true \ + -Dtrace.gid=22 -Dtrace.ppem=11 -Dtrace.out=/tmp/our-trace.txt + +Usage +----- + FT2_DEBUG=ttinterp:7 python3 trace_diff.py --gid 22 --ppem 11 --ours /tmp/our-trace.txt + +Both traces use a line format starting with `` `` and optionally carrying a point column +``Pn=(x,y)`` and/or a stack window ``# ...``. The program counter is always compared (catching +control-flow divergence). Pass --stack to also compare the operand window. To localize a *silent* +point-position divergence, pass --ft a FreeType point trace produced by ft_point_trace (which carries +``Pn=(x,y)``) and dump the FontBox trace with ``-Dtrace.point=n``; the point column is then compared. +""" +import argparse +import os +import re +import sys + +PC = re.compile(r"^\s*(\d+)\s+(\S+)") +POINT = re.compile(r"P\d+=\((-?\d+),(-?\d+)\)") +STACK = re.compile(r"#(.*)$") + + +def parse_trace(lines): + out = [] + for line in lines: + m = PC.match(line) + if not m: + continue + pc = int(m.group(1)) + op = m.group(2) + pm = POINT.search(line) + point = (int(pm.group(1)), int(pm.group(2))) if pm else None + sm = STACK.search(line) + stack = sm.group(1).split() if sm else [] + out.append((pc, op, point, stack)) + return out + + +def freetype_trace(font, gid, ppem): + import freetype # imported lazily so the rest of the tool works without it + face = freetype.Face(font) + face.set_pixel_sizes(0, ppem) + # FT2_DEBUG must be set in the environment before FreeType is first used + import io + import contextlib + # FreeType writes the trace to stderr; capture it + err_fd = os.dup(2) + r, w = os.pipe() + os.dup2(w, 2) + try: + face.load_glyph(gid, freetype.FT_LOAD_NO_AUTOHINT | freetype.FT_LOAD_TARGET_MONO) + finally: + os.dup2(err_fd, 2) + os.close(w) + data = os.read(r, 1 << 22).decode("latin1", "replace") + os.close(r) + os.close(err_fd) + return data.splitlines() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--font", default=os.path.join(os.path.dirname(__file__), "..", + "LiberationSans-Regular.ttf")) + ap.add_argument("--gid", type=int, required=True) + ap.add_argument("--ppem", type=int, default=11) + ap.add_argument("--ours", required=True, help="FontBox trace file from GlyphTraceTool") + ap.add_argument("--ft", help="FreeType trace file (e.g. from ft_point_trace); " + "if omitted, FreeType's ttinterp trace is captured via freetype-py") + ap.add_argument("--stack", action="store_true", help="also compare the operand stack window") + ap.add_argument("--point", action="store_true", help="compare the Pn=(x,y) point column") + args = ap.parse_args() + + if args.ft: + with open(args.ft, encoding="utf-8") as fh: + ft = parse_trace(fh.readlines()) + else: + ft = parse_trace(freetype_trace(args.font, args.gid, args.ppem)) + with open(args.ours, encoding="utf-8") as fh: + ours = parse_trace(fh.readlines()) + + # FreeType traces fpgm + prep + glyph; FontBox traces only the glyph program. Align on the tail. + print(f"FreeType instructions: {len(ft)} FontBox instructions: {len(ours)}") + ft_tail = ft[-len(ours):] if len(ft) >= len(ours) else ft + + for i, (a, b) in enumerate(zip(ft_tail, ours)): + pc_a, op_a, pt_a, st_a = a + pc_b, op_b, pt_b, st_b = b + mismatch = pc_a != pc_b + if args.stack and st_a[: len(st_b)] != st_b[: len(st_a)]: + mismatch = True + if args.point and pt_a is not None and pt_b is not None and pt_a != pt_b: + mismatch = True + if mismatch: + print(f"\nFirst divergence at aligned instruction {i} (the instruction that produced it " + "is the one before, where the point/stack still matched):") + print(f" FreeType: pc={pc_a:6d} {op_a:10s} pt={pt_a} # {' '.join(st_a)}") + print(f" FontBox : pc={pc_b:6d} {op_b:10s} pt={pt_b} # {' '.join(st_b)}") + print(" (context, FreeType | FontBox):") + for j in range(max(0, i - 4), i + 1): + print(f" {ft_tail[j][0]:6d} {ft_tail[j][1]:10s} {ft_tail[j][2]} | " + f"{ours[j][0]:6d} {ours[j][1]:10s} {ours[j][2]}") + return 1 + print(f"\nNo divergence over {min(len(ft_tail), len(ours))} aligned instructions for the compared " + "columns.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFont.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFont.java index 96a4f774d0f..cd79c437bb5 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFont.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFont.java @@ -312,6 +312,26 @@ public PDFontDescriptor getFontDescriptor() protected abstract GeneralPath getNormalizedPath(int code, PDType0Font parent) throws IOException; + /** + * Returns the grid-fitted (TrueType-hinted) normalized glyph path for the given character code at + * the given ppem, or {@code null} if hinting does not apply. Like + * {@link #getNormalizedPath(int, PDType0Font)} the result is normalized to the 1000 unit square. + * Only a descendant with embedded TrueType outlines can hint, so this returns {@code null} unless + * overridden. + * + * @param code character code in a PDF. Not to be confused with unicode. + * @param ppem the pixels-per-em the glyph will be rendered at + * @param parent the parent Type0 font. + * + * @return the hinted normalized glyph path, or null to use the unhinted path + * @throws java.io.IOException if the font could not be read + */ + protected GeneralPath getHintedNormalizedPath(int code, int ppem, PDType0Font parent) + throws IOException + { + return null; + } + /** * Returns true if this font contains a glyph for the given character code in a PDF. * diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2.java index 795cda173d6..57875650f6a 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDCIDFontType2.java @@ -448,6 +448,32 @@ protected GeneralPath getPath(int code, PDType0Font parent) throws IOException return new GeneralPath(); } + @Override + protected GeneralPath getHintedNormalizedPath(int code, int ppem, PDType0Font parent) + throws IOException + { + if (!isEmbedded() || (otf != null && otf.isPostScript())) + { + return null; + } + int gid = codeToGID(code, parent); + if (gid == 0) + { + return null; + } + GeneralPath path = ttf.getHintedPath(gid, ppem); + if (path == null) + { + return null; + } + if (ttf.getUnitsPerEm() != 1000) + { + float scale = 1000f / ttf.getUnitsPerEm(); + path.transform(AffineTransform.getScaleInstance(scale, scale)); + } + return path; + } + @Override protected GeneralPath getNormalizedPath(int code, PDType0Font parent) throws IOException { diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFont.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFont.java index dba0d0d233b..f6dd0d8b13c 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFont.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFont.java @@ -572,6 +572,32 @@ public GeneralPath getNormalizedPath(int code) throws IOException return path; } + @Override + public GeneralPath getHintedNormalizedPath(int code, int ppem) throws IOException + { + // only embedded glyf-based outlines carry hinting we can execute + if (!isEmbedded() || (otf != null && otf.isPostScript())) + { + return null; + } + int gid = codeToGID(code); + if (gid == 0) + { + return null; + } + GeneralPath path = ttf.getHintedPath(gid, ppem); + if (path == null) + { + return null; + } + if (ttf.getUnitsPerEm() != 1000) + { + float scale = 1000f / ttf.getUnitsPerEm(); + path.transform(AffineTransform.getScaleInstance(scale, scale)); + } + return path; + } + private GeneralPath getPathFromOutlines(int code) throws IOException { CFFFont cffFont = otf.getCFF().getFont(); diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDType0Font.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDType0Font.java index 82a54d36727..aa35e0b8ac5 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDType0Font.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDType0Font.java @@ -717,7 +717,16 @@ public GeneralPath getNormalizedPath(int code) throws IOException { return descendantFont.getNormalizedPath(code, this); } - + + @Override + public GeneralPath getHintedNormalizedPath(int code, int ppem) throws IOException + { + // PDCIDFont's implementation returns null and only PDCIDFontType2 overrides it, so the + // dispatch alone gives the hinted path for a TrueType-based descendant and null for a + // CFF-based one. + return descendantFont.getHintedNormalizedPath(code, ppem, this); + } + @Override public boolean hasGlyph(int code) throws IOException { diff --git a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDVectorFont.java b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDVectorFont.java index 4a6e66e3b1c..361696fa690 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDVectorFont.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/font/PDVectorFont.java @@ -46,6 +46,23 @@ public interface PDVectorFont */ GeneralPath getNormalizedPath(int code) throws IOException; + /** + * Returns the grid-fitted (TrueType-hinted) normalized glyph path for the given character code at + * the given ppem, or {@code null} if hinting does not apply (not an embedded TrueType outline font, + * a ppem the font's gasp table excludes, or hinting disabled). Like {@link #getNormalizedPath(int)} + * the result is normalized to the 1000 unit square. The default implementation returns + * {@code null}, i.e. no hinting. + * + * @param code character code in a PDF. Not to be confused with unicode. + * @param ppem the pixels-per-em the glyph will be rendered at + * @return the hinted normalized glyph path, or null to use the unhinted path + * @throws java.io.IOException if the font could not be read + */ + default GeneralPath getHintedNormalizedPath(int code, int ppem) throws IOException + { + return null; + } + /** * Returns true if this font contains a glyph for the given character code in a PDF. * diff --git a/pdfbox/src/main/java/org/apache/pdfbox/printing/PDFPrintable.java b/pdfbox/src/main/java/org/apache/pdfbox/printing/PDFPrintable.java index b982099f3c7..38c0e73ff1f 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/printing/PDFPrintable.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/printing/PDFPrintable.java @@ -58,6 +58,7 @@ public final class PDFPrintable implements Printable private final float dpi; private final boolean center; private boolean subsamplingAllowed = false; + private boolean hintingEnabled = false; private RenderingHints renderingHints = null; /** @@ -176,6 +177,29 @@ public void setSubsamplingAllowed(boolean subsamplingAllowed) this.subsamplingAllowed = subsamplingAllowed; } + /** + * Value indicating whether the renderer grid-fits (hints) TrueType glyph outlines by running the + * font's bytecode instructions at the size the glyph is drawn at. Hinting is off by default. + * + * @return true if TrueType hinting is enabled, false otherwise. + */ + public boolean isHintingEnabled() + { + return hintingEnabled; + } + + /** + * Sets a value instructing the renderer whether to grid-fit (hint) TrueType glyph outlines by + * running the font's bytecode instructions at the size the glyph is drawn at. Only embedded + * TrueType outline fonts are affected; other fonts render as before. Hinting is off by default. + * + * @param hintingEnabled The new value indicating if TrueType hinting is enabled. + */ + public void setHintingEnabled(boolean hintingEnabled) + { + this.hintingEnabled = hintingEnabled; + } + /** * Get the rendering hints. * @@ -294,6 +318,7 @@ public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) // draw to graphics using PDFRender graphics2D.setBackground(Color.WHITE); renderer.setSubsamplingAllowed(subsamplingAllowed); + renderer.setHintingEnabled(hintingEnabled); renderer.setRenderingHints(renderingHints); renderer.renderPageToGraphics(pageIndex, graphics2D, (float) scale, (float) scale, RenderDestination.PRINT); diff --git a/pdfbox/src/main/java/org/apache/pdfbox/rendering/GlyphCache.java b/pdfbox/src/main/java/org/apache/pdfbox/rendering/GlyphCache.java index 171b0fe85e0..7ac0ba7d4b4 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/rendering/GlyphCache.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/rendering/GlyphCache.java @@ -38,11 +38,45 @@ final class GlyphCache private final PDVectorFont font; private final Map cache = new HashMap<>(); + private final Map hintedCache = new HashMap<>(); GlyphCache(PDVectorFont font) { this.font = font; } + + /** + * Returns the grid-fitted (hinted) glyph path for the given character code at the given ppem, + * falling back to the unhinted path when the font does not hint that glyph/ppem. Results are + * cached per {@code (code, ppem)}. + * + * @param code character code in a PDF + * @param ppem the pixels-per-em the glyph will be rendered at + * @return the hinted path if available, otherwise the unhinted path + */ + public GeneralPath getPathForCharacterCode(int code, int ppem) + { + long key = ((long) ppem << 32) | (code & 0xFFFFFFFFL); + GeneralPath cached = hintedCache.get(key); + if (cached != null) + { + return cached; + } + GeneralPath path = null; + try + { + path = font.getHintedNormalizedPath(code, ppem); + } + catch (IOException e) + { + String fontName = ((PDFontLike) font).getName(); + LOG.warn(() -> "Hinting failed for code " + code + " in font " + fontName, e); + } + // fall back to the unhinted path (itself cached by code); cache the decision per (code, ppem) + GeneralPath result = path != null ? path : getPathForCharacterCode(code); + hintedCache.put(key, result); + return result; + } public GeneralPath getPathForCharacterCode(int code) { diff --git a/pdfbox/src/main/java/org/apache/pdfbox/rendering/PDFRenderer.java b/pdfbox/src/main/java/org/apache/pdfbox/rendering/PDFRenderer.java index 27326cf9d7a..d34deac934e 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/rendering/PDFRenderer.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/rendering/PDFRenderer.java @@ -62,6 +62,7 @@ public class PDFRenderer private AnnotationFilter annotationFilter = annotation -> true; private boolean subsamplingAllowed = false; + private boolean hintingEnabled = false; private RenderDestination defaultDestination; @@ -133,6 +134,29 @@ public void setSubsamplingAllowed(boolean subsamplingAllowed) this.subsamplingAllowed = subsamplingAllowed; } + /** + * Value indicating whether the renderer grid-fits (hints) TrueType glyph outlines by running the + * font's bytecode instructions at the size the glyph is drawn at. Hinting is off by default. + * + * @return true if TrueType hinting is enabled, false otherwise. + */ + public boolean isHintingEnabled() + { + return hintingEnabled; + } + + /** + * Sets a value instructing the renderer whether to grid-fit (hint) TrueType glyph outlines by + * running the font's bytecode instructions at the size the glyph is drawn at. Only embedded + * TrueType outline fonts are affected; other fonts render as before. Hinting is off by default. + * + * @param hintingEnabled The new value indicating if TrueType hinting is enabled. + */ + public void setHintingEnabled(boolean hintingEnabled) + { + this.hintingEnabled = hintingEnabled; + } + /** * @return the defaultDestination */ @@ -353,7 +377,7 @@ public BufferedImage renderImage(int pageIndex, float scale, ImageType imageType RenderingHints actualRenderingHints = renderingHints == null ? createDefaultRenderingHints(g) : renderingHints; PageDrawerParameters parameters - = new PageDrawerParameters(this, page, subsamplingAllowed, destination, + = new PageDrawerParameters(this, page, subsamplingAllowed, hintingEnabled, destination, actualRenderingHints, imageDownscalingOptimizationThreshold); PageDrawer drawer = createPageDrawer(parameters); drawer.drawPage(g, cropBox); @@ -469,7 +493,7 @@ public void renderPageToGraphics(int pageIndex, Graphics2D graphics, float scale RenderingHints actualRenderingHints = renderingHints == null ? createDefaultRenderingHints(graphics) : renderingHints; PageDrawerParameters parameters = - new PageDrawerParameters(this, page, subsamplingAllowed, destination, + new PageDrawerParameters(this, page, subsamplingAllowed, hintingEnabled, destination, actualRenderingHints, imageDownscalingOptimizationThreshold); PageDrawer drawer = createPageDrawer(parameters); drawer.drawPage(graphics, cropBox); diff --git a/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java b/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java index bc76d2486d8..ed6c1a91d33 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawer.java @@ -134,15 +134,16 @@ public class PageDrawer extends PDFGraphicsStreamEngine // parent document renderer - note: this is needed for not-yet-implemented resource caching private final PDFRenderer renderer; - + private final boolean subsamplingAllowed; - + private final boolean hintingEnabled; + // the graphics device to draw to, xform is the initial transform of the device (i.e. DPI) private Graphics2D graphics; private AffineTransform xform; private float xformScalingFactorX; private float xformScalingFactorY; - + // the page box to draw (usually the crop box but may be another) private PDRectangle pageSize; @@ -153,13 +154,13 @@ public class PageDrawer extends PDFGraphicsStreamEngine // clipping winding rule used for the clipping path private int clipWindingRule = -1; private GeneralPath linePath = new GeneralPath(); - + // last clipping path private List lastClips; // clip when drawPage() is called, can be null, must be intersected when clipping private Shape initialClip; - + // shapes of glyphs being drawn to be used for clipping private List textClippings; @@ -167,7 +168,7 @@ public class PageDrawer extends PDFGraphicsStreamEngine private final Map glyphCaches = new HashMap<>(); private final TilingPaintFactory tilingPaintFactory = new TilingPaintFactory(this); - + private final Deque transparencyGroupStack = new ArrayDeque<>(); // if greater zero the content is hidden and will not be rendered @@ -194,6 +195,7 @@ public PageDrawer(PageDrawerParameters parameters) super(parameters.getPage()); this.renderer = parameters.getRenderer(); this.subsamplingAllowed = parameters.isSubsamplingAllowed(); + this.hintingEnabled = parameters.isHintingEnabled(); this.destination = parameters.getDestination(); this.renderingHints = parameters.getRenderingHints(); this.imageDownscalingOptimizationThreshold = @@ -202,7 +204,7 @@ public PageDrawer(PageDrawerParameters parameters) /** * Return the AnnotationFilter. - * + * * @return the AnnotationFilter */ public AnnotationFilter getAnnotationFilter() @@ -212,19 +214,19 @@ public AnnotationFilter getAnnotationFilter() /** * Set the AnnotationFilter. - * + * *

Allows to only render annotation accepted by the filter. - * + * * @param annotationFilter the AnnotationFilter */ public void setAnnotationFilter(AnnotationFilter annotationFilter) { this.annotationFilter = annotationFilter; } - + /** * Returns the parent renderer. - * + * * @return the parent renderer */ public final PDFRenderer getRenderer() @@ -234,7 +236,7 @@ public final PDFRenderer getRenderer() /** * Returns the underlying Graphics2D. May be null if drawPage has not yet been called. - * + * * @return the underlying Graphics2D */ protected final Graphics2D getGraphics() @@ -244,7 +246,7 @@ protected final Graphics2D getGraphics() /** * Returns the current line path. This is reset to empty after each fill/stroke. - * + * * @return the current line path */ protected final GeneralPath getLinePath() @@ -262,7 +264,7 @@ private void setRenderingHints() /** * Draws the page to the requested context. - * + * * @param g The graphics context to draw onto. * @param pageSize The size of the page to draw. * @throws IOException If there is an IO error while drawing the page. @@ -320,13 +322,13 @@ void drawTilingPattern(Graphics2D g, PDTilingPattern pattern, PDColorSpace color lastClips = null; Shape savedInitialClip = initialClip; initialClip = null; - + boolean savedFlipTG = flipTG; flipTG = true; setRenderingHints(); processTilingPattern(pattern, color, colorSpace, patternMatrix); - + flipTG = savedFlipTG; graphics = savedGraphics; linePath = savedLinePath; @@ -337,15 +339,15 @@ void drawTilingPattern(Graphics2D g, PDTilingPattern pattern, PDColorSpace color private float clampColor(float color) { - return color < 0 ? 0 : (color > 1 ? 1 : color); + return color < 0 ? 0 : (color > 1 ? 1 : color); } /** * Returns an AWT paint for the given PDColor. - * + * * @param color The color to get a paint for. This can be an actual color or a pattern. * @return an AWT paint for the given PDColor - * + * * @throws IOException if the AWT paint could not be created */ protected Paint getPaint(PDColor color) throws IOException @@ -384,7 +386,7 @@ else if (!(colorSpace instanceof PDPattern)) else { // uncolored tiling pattern - return tilingPaintFactory.create(tilingPattern, + return tilingPaintFactory.create(tilingPattern, patternSpace.getUnderlyingColorSpace(), color, xform); } } @@ -460,7 +462,7 @@ public void endText() throws IOException { endTextClip(); } - + /** * Begin buffering the text clipping path, if any. */ @@ -477,7 +479,7 @@ private void endTextClip() { PDGraphicsState state = getGraphicsState(); RenderingMode renderingMode = state.getTextState().getRenderingMode(); - + // apply the buffered clip as one area if (renderingMode.isClip() && !textClippings.isEmpty()) { @@ -488,7 +490,7 @@ private void endTextClip() state.intersectClippingPath(path); textClippings = new ArrayList<>(); - // PDFBOX-3681: lastClip needs to be reset, because after intersection it is still the same + // PDFBOX-3681: lastClip needs to be reset, because after intersection it is still the same // object, thus setClip() would believe that it is cached. lastClips = null; } @@ -510,13 +512,53 @@ protected void showFontGlyph(Matrix textRenderingMatrix, PDFont font, int code, glyphCaches.put(font, cache); } - GeneralPath path = cache.getPathForCharacterCode(code); + // Grid-fitting is off by default; PDFRenderer.setHintingEnabled(true) turns it on. While it + // is off we never derive a ppem and take the plain, code-keyed cache path, so the feature + // costs nothing when disabled. + int ppem = 0; + if (hintingEnabled) + { + // hintingPpem expects the glyph-space-to-device transform, but 'at' only maps glyph space + // to PDF user space (points) - the device scale lives in 'xform', which the Graphics2D + // applies separately. Compose it in so the ppem is the true device pixels-per-em; + // otherwise we grid-fit at the font's point size (e.g. 7) instead of its rendered size + // (e.g. 29 at 300dpi). + AffineTransform deviceAt = at; + if (xform != null) + { + deviceAt = new AffineTransform(xform); + deviceAt.concatenate(at); + } + ppem = hintingPpem(deviceAt); + } + GeneralPath path = ppem > 0 ? cache.getPathForCharacterCode(code, ppem) + : cache.getPathForCharacterCode(code); drawGlyph(path, font, code, displacement, at); } + /** + * Derives the pixels-per-em for grid-fitting from the glyph-space-to-device transform, or returns + * 0 when the glyph is too small / degenerate to hint. The ppem is the magnitude of the transform's + * vertical basis vector, i.e. the device height of one em, so it is correct under rotation: the + * glyph is grid-fit in its own (upright) coordinate space and the full transform — including any + * rotation — is then applied to the hinted outline by the caller, exactly as FreeType does for + * rotated text (90-degree vertical CJK columns being the common case). The path fed through + * {@code at} is normalized to 1000 units/em, so one em is 1000 units in {@code at}'s input space. + * + * @param at the transform mapping normalized (1000/em) glyph coordinates to device space + * @return the ppem to hint at, or 0 to render unhinted + */ + static int hintingPpem(AffineTransform at) + { + // length of the y basis vector = device pixels per normalized unit, rotation-invariant + double scaleY = Math.hypot(at.getShearX(), at.getScaleY()); + int ppem = (int) Math.round(1000.0 * scaleY); + return ppem > 0 ? ppem : 0; + } + /** * Renders a glyph. - * + * * @param path the GeneralPath for the glyph * @param font the font * @param code character code @@ -625,7 +667,7 @@ private Paint applySoftMaskToPaint(Paint parentPaint, PDSoftMask softMask) throw } } } - TransparencyGroup transparencyGroup = new TransparencyGroup(form, true, + TransparencyGroup transparencyGroup = new TransparencyGroup(form, true, softMask.getInitialTransformationMatrix(), backdropColor); BufferedImage image = transparencyGroup.getImage(); if (image == null) @@ -663,7 +705,7 @@ private BufferedImage adjustImage(BufferedImage gray) Rectangle originalBounds = new Rectangle(gray.getWidth(), gray.getHeight()); Rectangle2D transformedBounds = at.createTransformedShape(originalBounds).getBounds2D(); - at.preConcatenate(AffineTransform.getTranslateInstance(-transformedBounds.getMinX(), + at.preConcatenate(AffineTransform.getTranslateInstance(-transformedBounds.getMinX(), -transformedBounds.getMinY())); int width = (int) Math.ceil(transformedBounds.getWidth()); @@ -851,7 +893,7 @@ public void fillPath(int windingRule) throws IOException graphics.setPaint(getNonStrokingPaint()); graphics.fill(shape); } - + linePath.reset(); if (noAntiAlias) @@ -973,7 +1015,7 @@ public void clip(int windingRule) getGraphicsState().intersectClippingPath(adjustClip(linePath)); } - // PDFBOX-3836: lastClip needs to be reset, because after intersection it is still the same + // PDFBOX-3836: lastClip needs to be reset, because after intersection it is still the same // object, thus setClip() would believe that it is cached. lastClips = null; @@ -1016,7 +1058,7 @@ public void endPath() { linePath.reset(); } - + /** * PDFBOX-5715 / PR#73: This was added to fix a problem with missing fine lines when printing * on MacOS. Lines vanish because CPrinterJob sets graphics scale to 1 for Printable so after @@ -1028,7 +1070,7 @@ public void endPath() * here. * * @param linePath - * @return + * @return */ private GeneralPath adjustClip(GeneralPath linePath) { @@ -1125,10 +1167,10 @@ public void drawImage(PDImage pdImage) throws IOException { // The earlier code for stencils (see "else") doesn't work with patterns because the // CTM is not taken into consideration. - // this code is based on the fact that it is easily possible to draw the mask and + // this code is based on the fact that it is easily possible to draw the mask and // the paint at the correct place with the existing code, but not in one step. // Thus what we do is to draw both in separate images, then combine the two and draw - // the result. + // the result. // Note that the device scale is not used. In theory, some patterns can get better // at higher resolutions but the stencil would become more and more "blocky". // If anybody wants to do this, have a look at the code in showTransparencyGroup(). @@ -1511,7 +1553,7 @@ private void drawBufferedImage(PDImage pdImage, BufferedImage image, AffineTrans // will trigger the workaround. Because of the slowness we only do it if the user // expects quality rendering and interpolation. Matrix imageTransformMatrix = new Matrix(imageTransform); - Matrix graphicsTransformMatrix = new Matrix(originalTransform); + Matrix graphicsTransformMatrix = new Matrix(originalTransform); float scaleX = Math.abs(imageTransformMatrix.getScalingFactorX() * graphicsTransformMatrix.getScalingFactorX()); float scaleY = Math.abs(imageTransformMatrix.getScalingFactorY() * graphicsTransformMatrix.getScalingFactorY()); @@ -1580,7 +1622,7 @@ private BufferedImage applyTransferFunction(BufferedImage image, COSBase transfe bim = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_RGB); } - // prepare transfer functions (either one per color or one for all) + // prepare transfer functions (either one per color or one for all) // and maps (actually arrays[256] to be faster) to avoid calculating values several times Integer[] rMap; Integer[] gMap; @@ -1840,7 +1882,7 @@ public void showTransparencyGroup(PDTransparencyGroup form) throws IOException /** * For advanced users, to extract the transparency group into a separate graphics device. - * + * * @param form the transparency group to be extracted * @param graphics the target graphics device * @throws IOException if the transparency group could not be extracted @@ -1944,7 +1986,7 @@ private final class TransparencyGroup * masks. * @throws IOException */ - private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix ctm, + private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix ctm, PDColor backdropColor) throws IOException { Graphics2D savedGraphics = graphics; @@ -2049,8 +2091,8 @@ private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix c } if (isSoftMask && backdropColor != null) { - // "If the subtype is Luminosity, the transparency group XObject G shall be - // composited with a fully opaque backdrop whose colour is everywhere defined + // "If the subtype is Luminosity, the transparency group XObject G shall be + // composited with a fully opaque backdrop whose colour is everywhere defined // by the soft-mask dictionary's BC entry." g.setBackground(new Color(backdropColor.toRGB())); g.clearRect(0, 0, width, height); @@ -2103,7 +2145,7 @@ private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix c ((GroupGraphics) graphics).removeBackdrop(backdropImage, backdropX, backdropY); } } - finally + finally { flipTG = savedFlipTG; lastClips = savedLastClips; @@ -2118,7 +2160,7 @@ private TransparencyGroup(PDTransparencyGroup form, boolean isSoftMask, Matrix c } // http://stackoverflow.com/a/21181943/535646 - private BufferedImage create2ByteGrayAlphaImage(int width, int height) + private BufferedImage create2ByteGrayAlphaImage(int width, int height) { // gray + alpha int[] bandOffsets = {1, 0}; @@ -2339,7 +2381,7 @@ private boolean isHiddenOCMD(PDOptionalContentMembershipDictionary ocmd) List visibles = new ArrayList<>(oCGs.size()); oCGs.forEach(prop -> visibles.add(!isHiddenOCG(prop))); COSName visibilityPolicy = ocmd.getVisibilityPolicy(); - + // visible if any of the entries in OCGs are OFF if (COSName.ANY_OFF.equals(visibilityPolicy)) { diff --git a/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawerParameters.java b/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawerParameters.java index 3cf1c4edd83..9b0f66017ab 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawerParameters.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/rendering/PageDrawerParameters.java @@ -33,6 +33,7 @@ public final class PageDrawerParameters private final PDFRenderer renderer; private final PDPage page; private final boolean subsamplingAllowed; + private final boolean hintingEnabled; private final RenderDestination destination; private final RenderingHints renderingHints; private final float imageDownscalingOptimizationThreshold; @@ -41,12 +42,13 @@ public final class PageDrawerParameters * Package-private constructor. */ PageDrawerParameters(PDFRenderer renderer, PDPage page, boolean subsamplingAllowed, - RenderDestination destination, RenderingHints renderingHints, - float imageDownscalingOptimizationThreshold) + boolean hintingEnabled, RenderDestination destination, + RenderingHints renderingHints, float imageDownscalingOptimizationThreshold) { this.renderer = renderer; this.page = page; this.subsamplingAllowed = subsamplingAllowed; + this.hintingEnabled = hintingEnabled; this.destination = destination; this.renderingHints = renderingHints; this.imageDownscalingOptimizationThreshold = imageDownscalingOptimizationThreshold; @@ -82,6 +84,16 @@ public boolean isSubsamplingAllowed() return subsamplingAllowed; } + /** + * Returns whether TrueType glyph outlines are grid-fitted (hinted). + * + * @return true if TrueType hinting is enabled + */ + public boolean isHintingEnabled() + { + return hintingEnabled; + } + /** * @return the destination */ diff --git a/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFontHintingTest.java b/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFontHintingTest.java new file mode 100644 index 00000000000..26af3a2a7f6 --- /dev/null +++ b/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDTrueTypeFontHintingTest.java @@ -0,0 +1,123 @@ +/* + * 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.pdmodel.font; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.geom.GeneralPath; +import java.awt.geom.PathIterator; +import java.awt.geom.Rectangle2D; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.encoding.WinAnsiEncoding; +import org.junit.jupiter.api.Test; + +/** + * Verifies the render-path hinting wiring at the font level: an embedded TrueType font returns a + * grid-fitted normalized path that differs from the unhinted one, is in the same 1000/em space, and + * respects the gasp gate. + */ +class PDTrueTypeFontHintingTest +{ + // PDTrueTypeFont.load() consumes and closes the stream, so each caller needs a fresh one + private static InputStream fontStream() + { + return PDTrueTypeFontHintingTest.class.getResourceAsStream( + "/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"); + } + + @Test + void testHintedNormalizedPathDiffersFromUnhinted() throws IOException + { + try (PDDocument doc = new PDDocument()) + { + PDTrueTypeFont font = PDTrueTypeFont.load(doc, fontStream(), WinAnsiEncoding.INSTANCE); + int code = 'H'; + + GeneralPath hinted = font.getHintedNormalizedPath(code, 16); + assertNotNull(hinted, "expected a hinted path for 'H' at 16ppem"); + GeneralPath unhinted = font.getNormalizedPath(code); + + // hinting must change the outline + assertFalse(Arrays.equals(flatten(hinted), flatten(unhinted)), + "hinted path should differ from the unhinted path"); + + // and it must still be in the 1000-unit em square (an 'H' cap height is several hundred) + Rectangle2D bounds = hinted.getBounds2D(); + assertTrue(bounds.getMaxY() > 300 && bounds.getMaxY() < 1000, + "hinted path should be normalized to 1000/em, was maxY=" + bounds.getMaxY()); + } + } + + @Test + void testGaspGateReturnsNullAtSmallPpem() throws IOException + { + try (PDDocument doc = new PDDocument()) + { + PDTrueTypeFont font = PDTrueTypeFont.load(doc, fontStream(), WinAnsiEncoding.INSTANCE); + // LiberationSans gasp disables grid-fitting at <= 8 ppem + assertNull(font.getHintedNormalizedPath('H', 8)); + assertNotNull(font.getHintedNormalizedPath('H', 16)); + } + } + + /** Only an embedded outline carries bytecode we can execute; a substituted font must not hint. */ + @Test + void testNonEmbeddedFontDoesNotHint() throws IOException + { + COSDictionary dict = new COSDictionary(); + dict.setItem(COSName.TYPE, COSName.FONT); + dict.setItem(COSName.SUBTYPE, COSName.TRUE_TYPE); + dict.setName(COSName.BASE_FONT, "Helvetica"); + + PDTrueTypeFont font = new PDTrueTypeFont(dict, null); + assertFalse(font.isEmbedded(), "font should not be embedded"); + // the substituted font still draws, so a null hinted path is the embedded check talking + // rather than a font that cannot produce an outline at all + assertFalse(font.getNormalizedPath('H').getPathIterator(null).isDone(), + "expected the substitute font to produce an outline"); + assertNull(font.getHintedNormalizedPath('H', 16)); + } + + private static double[] flatten(GeneralPath path) + { + double[] coords = new double[6]; + java.util.List out = new java.util.ArrayList<>(); + for (PathIterator it = path.getPathIterator(null); !it.isDone(); it.next()) + { + out.add((double) it.currentSegment(coords)); + for (double c : coords) + { + out.add(c); + } + } + double[] array = new double[out.size()]; + for (int i = 0; i < array.length; i++) + { + array[i] = out.get(i); + } + return array; + } +} diff --git a/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDType0FontHintingTest.java b/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDType0FontHintingTest.java new file mode 100644 index 00000000000..fa38db9d32e --- /dev/null +++ b/pdfbox/src/test/java/org/apache/pdfbox/pdmodel/font/PDType0FontHintingTest.java @@ -0,0 +1,155 @@ +/* + * 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.pdmodel.font; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.geom.GeneralPath; +import java.awt.geom.PathIterator; +import java.awt.geom.Rectangle2D; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.fontbox.ttf.TTFParser; +import org.apache.fontbox.ttf.TrueTypeFont; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.io.RandomAccessReadBufferedFile; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.Test; + +/** + * The CID half of the render-path hinting wiring. {@link PDCIDFontType2#getHintedNormalizedPath} is a + * near-copy of the {@link PDTrueTypeFont} one but had no test of its own, and {@link PDType0Font} + * forwards to it only when the descendant really is a CIDFontType2. + */ +class PDType0FontHintingTest +{ + /** + * Embeds the font whole (no subsetting) so the encoding is Identity and a character code is its + * own glyph id, which keeps the test about hinting rather than about CID mapping. + */ + private static PDType0Font load(PDDocument doc, int[] gidOut) throws IOException, URISyntaxException + { + URL url = TrueTypeFont.class.getResource( + "/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"); + File fontFile = new File(url.toURI()); + + TrueTypeFont ttf = new TTFParser().parse(new RandomAccessReadBufferedFile(fontFile)); + gidOut[0] = ttf.getUnicodeCmapLookup().getGlyphId('H'); + assertTrue(gidOut[0] > 0, "no glyph for 'H'"); + return PDType0Font.load(doc, ttf, false); + } + + @Test + void testHintedNormalizedPathDiffersFromUnhinted() throws IOException, URISyntaxException + { + try (PDDocument doc = new PDDocument()) + { + int[] gid = new int[1]; + PDType0Font font = load(doc, gid); + + GeneralPath hinted = font.getHintedNormalizedPath(gid[0], 16); + assertNotNull(hinted, "expected a hinted path for 'H' at 16ppem"); + GeneralPath unhinted = font.getNormalizedPath(gid[0]); + + assertFalse(Arrays.equals(flatten(hinted), flatten(unhinted)), + "hinted path should differ from the unhinted path"); + + // and it must still be in the 1000-unit em square (an 'H' cap height is several hundred) + Rectangle2D bounds = hinted.getBounds2D(); + assertTrue(bounds.getMaxY() > 300 && bounds.getMaxY() < 1000, + "hinted path should be normalized to 1000/em, was maxY=" + bounds.getMaxY()); + } + } + + @Test + void testGaspGateReturnsNullAtSmallPpem() throws IOException, URISyntaxException + { + try (PDDocument doc = new PDDocument()) + { + int[] gid = new int[1]; + PDType0Font font = load(doc, gid); + // LiberationSans gasp disables grid-fitting at <= 8 ppem + assertNull(font.getHintedNormalizedPath(gid[0], 8)); + assertNotNull(font.getHintedNormalizedPath(gid[0], 16)); + } + } + + /** Only an embedded outline carries bytecode we can execute; a substituted font must not hint. */ + @Test + void testNonEmbeddedFontDoesNotHint() throws IOException + { + PDType0Font font = nonEmbedded(); + assertFalse(font.getDescendantFont().isEmbedded(), "font should not be embedded"); + // the substituted font still draws, so a null hinted path is the embedded check talking + // rather than a font that cannot produce an outline at all + int gid = font.getDescendantFont().codeToGID('H', font); + assertFalse(font.getNormalizedPath(gid).getPathIterator(null).isDone(), + "expected the substitute font to produce an outline"); + assertNull(font.getHintedNormalizedPath(gid, 16)); + } + + /** Builds the Type0/CIDFontType2 dictionary pair a PDF uses when it does not embed the font. */ + private static PDType0Font nonEmbedded() throws IOException + { + COSDictionary cid = new COSDictionary(); + cid.setItem(COSName.TYPE, COSName.FONT); + cid.setItem(COSName.SUBTYPE, COSName.CID_FONT_TYPE2); + cid.setName(COSName.BASE_FONT, "Helvetica"); + + COSArray descendants = new COSArray(); + descendants.add(cid); + + COSDictionary type0 = new COSDictionary(); + type0.setItem(COSName.TYPE, COSName.FONT); + type0.setItem(COSName.SUBTYPE, COSName.TYPE0); + type0.setName(COSName.BASE_FONT, "Helvetica"); + type0.setItem(COSName.ENCODING, COSName.IDENTITY_H); + type0.setItem(COSName.DESCENDANT_FONTS, descendants); + return new PDType0Font(type0, null); + } + + private static double[] flatten(GeneralPath path) + { + double[] coords = new double[6]; + List out = new ArrayList<>(); + for (PathIterator it = path.getPathIterator(null); !it.isDone(); it.next()) + { + out.add((double) it.currentSegment(coords)); + for (double c : coords) + { + out.add(c); + } + } + double[] array = new double[out.size()]; + for (int i = 0; i < array.length; i++) + { + array[i] = out.get(i); + } + return array; + } +} diff --git a/pdfbox/src/test/java/org/apache/pdfbox/rendering/HintingPpemTest.java b/pdfbox/src/test/java/org/apache/pdfbox/rendering/HintingPpemTest.java new file mode 100644 index 00000000000..63772a124dc --- /dev/null +++ b/pdfbox/src/test/java/org/apache/pdfbox/rendering/HintingPpemTest.java @@ -0,0 +1,84 @@ +/* + * 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.rendering; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.awt.geom.AffineTransform; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link PageDrawer#hintingPpem(AffineTransform)} - the derivation of the grid-fitting ppem from + * the glyph-space-to-device transform, including the rule that rotated/sheared transforms are not + * hinted. The input transform maps 1000-units-per-em glyph coordinates to device pixels. + */ +class HintingPpemTest +{ + @Test + void testUprightScaleGivesPpem() + { + // 16px em: one normalized unit is 16/1000 device pixels + assertEquals(16, PageDrawer.hintingPpem(AffineTransform.getScaleInstance(0.016, 0.016))); + assertEquals(11, PageDrawer.hintingPpem(AffineTransform.getScaleInstance(0.011, 0.011))); + } + + @Test + void testYFlipIsStillHinted() + { + // typical PDF-to-device flip has a negative y scale but is still axis-aligned + AffineTransform at = new AffineTransform(0.024, 0, 0, -0.024, 100, 200); + assertEquals(24, PageDrawer.hintingPpem(at)); + } + + @Test + void testAnisotropicUsesVerticalPpem() + { + // ppem is taken from the vertical scale + AffineTransform at = new AffineTransform(0.020, 0, 0, -0.016, 0, 0); + assertEquals(16, PageDrawer.hintingPpem(at)); + } + + @Test + void testRotationIsStillHinted() + { + // rotated text (e.g. 90-degree vertical CJK) is grid-fit in upright glyph space at the + // transform's scale; the rotation is applied afterwards. ppem is the rotation-invariant scale. + AffineTransform at = AffineTransform.getScaleInstance(0.016, 0.016); + at.rotate(Math.toRadians(30)); + assertEquals(16, PageDrawer.hintingPpem(at)); + + AffineTransform vertical = new AffineTransform(0, 0.016, -0.016, 0, 0, 0); // 90-degree rotation + assertEquals(16, PageDrawer.hintingPpem(vertical)); + } + + @Test + void testShearTakesVerticalScale() + { + // a sheared (fake-italic) transform is hinted at its vertical-basis magnitude + AffineTransform at = new AffineTransform(0.016, 0, 0.006, 0.016, 0, 0); + assertEquals((int) Math.round(1000 * Math.hypot(0.006, 0.016)), PageDrawer.hintingPpem(at)); + } + + @Test + void testDegenerateTransformIsNotHinted() + { + assertEquals(0, PageDrawer.hintingPpem(AffineTransform.getScaleInstance(0, 0))); + // sub-half-pixel em rounds to 0 ppem -> no hinting + assertEquals(0, PageDrawer.hintingPpem(AffineTransform.getScaleInstance(0.0004, 0.0004))); + } +} diff --git a/pdfbox/src/test/java/org/apache/pdfbox/rendering/RenderHintingIntegrationTest.java b/pdfbox/src/test/java/org/apache/pdfbox/rendering/RenderHintingIntegrationTest.java new file mode 100644 index 00000000000..5b706149efe --- /dev/null +++ b/pdfbox/src/test/java/org/apache/pdfbox/rendering/RenderHintingIntegrationTest.java @@ -0,0 +1,114 @@ +/* + * 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.rendering; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDTrueTypeFont; +import org.apache.pdfbox.pdmodel.font.encoding.WinAnsiEncoding; +import org.junit.jupiter.api.Test; + +/** + * Proves that hinting is actually wired into the render path: when hinting is enabled, a page of + * embedded TrueType text rasterizes to a different image than with it disabled. + */ +class RenderHintingIntegrationTest +{ + // PDTrueTypeFont.load() consumes and closes the stream, so each caller needs a fresh one + private static InputStream fontStream() + { + return RenderHintingIntegrationTest.class.getResourceAsStream( + "/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"); + } + + @Test + void testHintingChangesRenderedPixels() throws IOException + { + byte[] pdf = buildPdf(); + BufferedImage off = render(pdf, false); + BufferedImage on = render(pdf, true); + + assertEquals(off.getWidth(), on.getWidth()); + assertEquals(off.getHeight(), on.getHeight()); + assertTrue(countDifferences(off, on) > 0, + "enabling hinting should change the rendered glyph pixels"); + } + + /** A page must rasterize identically across two renders when hinting stays disabled. */ + @Test + void testDisabledHintingIsDeterministic() throws IOException + { + byte[] pdf = buildPdf(); + assertEquals(0, countDifferences(render(pdf, false), render(pdf, false))); + } + + private static byte[] buildPdf() throws IOException + { + try (PDDocument doc = new PDDocument()) + { + PDPage page = new PDPage(new PDRectangle(160, 60)); + doc.addPage(page); + PDTrueTypeFont font = PDTrueTypeFont.load(doc, fontStream(), WinAnsiEncoding.INSTANCE); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) + { + cs.beginText(); + cs.setFont(font, 11); + cs.newLineAtOffset(8, 24); + cs.showText("Hamburgefons 123"); + cs.endText(); + } + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + doc.save(out); + return out.toByteArray(); + } + } + + private static BufferedImage render(byte[] pdf, boolean hinting) throws IOException + { + try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdf)) + { + PDFRenderer renderer = new PDFRenderer(doc); + renderer.setHintingEnabled(hinting); + return renderer.renderImageWithDPI(0, 96); + } + } + + private static int countDifferences(BufferedImage a, BufferedImage b) + { + int diff = 0; + for (int y = 0; y < a.getHeight(); y++) + { + for (int x = 0; x < a.getWidth(); x++) + { + if (a.getRGB(x, y) != b.getRGB(x, y)) + { + diff++; + } + } + } + return diff; + } +}