Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@
# (a CRLF shebang is unrunnable there); the Windows `mvnw.cmd` batch stays CRLF.
mvnw text eol=lf
mvnw.cmd text eol=crlf

# PDFs are binary. Without this, core.autocrlf treats the committed README
# previews as text on checkout and corrupts them in the working tree — which
# also blocks any branch switch (the file shows as modified forever). Found when the
# A/B benchmark could not return to its branch across a preview change.
*.pdf binary
21 changes: 18 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,27 @@ follow semantic versioning; release dates are ISO 8601.
that measures and draws — where substituting them with `?` would have put a visible
mark on the page and given a zero-width character a width.

Two limits are worth knowing. Plain text extraction and copy-paste read the PDF's
One limit is worth knowing. Plain text extraction and copy-paste read the PDF's
content stream, which carries the visual order — selecting a Hebrew line out of a
produced PDF yields its characters reversed. Undoing that would take `ActualText`
marked content, which this release does not write; the DOCX export is unaffected,
since Word receives logical text. And Arabic renders unjoined for now: contextual
letter forms are the next step.
since Word receives logical text.

- **Arabic joins.** Arabic letters change shape by position, and a font does that
through OpenType `GSUB` — which a PDF never executes: `showText` walks the font's
`cmap` and nothing else. The engine now shapes Arabic itself, mapping each letter to
its contextual presentation form (and lam-alef to its ligature) before measurement,
so what is measured is what is drawn. Vowel points and direction marks are
transparent to the join, as Unicode's joining rules say. PowerPoint gets the base
letters back — it shapes Arabic itself, and frozen forms would end up in a file
users search and copy from — and Word was never given forms to begin with. A font
that carries the letters but not the forms (the `GSUB`-only families) now degrades
to unjoined base letters instead of `?`, which costs the joining rather than the
text. In right-to-left runs, paired punctuation is mirrored at the PDF seam
(UAX #9 L4), so a parenthesis in Hebrew faces what it encloses. The mirrored set is
the punctuation that occurs in documents — parentheses, brackets, braces, angle
brackets, guillemets — rather than the whole Unicode mirroring table; a mathematical
operator passes through drawn as written.

### Fonts

Expand Down
Binary file modified assets/readme/examples/text-direction.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.demcha.compose.document.layout;

import com.demcha.compose.engine.text.bidi.ArabicShaper;
import com.demcha.compose.document.layout.payloads.*;
import com.demcha.compose.document.node.*;
import com.demcha.compose.document.style.DocumentDashPattern;
Expand Down Expand Up @@ -65,7 +66,9 @@ static InlineTextToken of(String text,
TextStyle style,
DocumentLinkTarget linkTarget,
TextMeasurementSystem measurement) {
String safeText = text == null ? "" : text;
// Shaped at construction — the width stored here is what wrapping fits and
// what the page draws, so the two must be the same text.
String safeText = ArabicShaper.shape(text == null ? "" : text);
TextStyle safeStyle = style == null ? TextStyle.DEFAULT_STYLE : style;
double width = safeText.isEmpty() ? 0.0 : measurement.textWidth(safeStyle, safeText);
return new InlineTextToken(safeText, safeStyle, linkTarget, width, null, null, 0.0, 0.0);
Expand All @@ -79,7 +82,7 @@ static InlineTextToken ofHighlight(String text,
double leadPad,
double trailPad,
TextMeasurementSystem measurement) {
String safeText = text == null ? "" : text;
String safeText = ArabicShaper.shape(text == null ? "" : text);
TextStyle safeStyle = style == null ? TextStyle.DEFAULT_STYLE : style;
double width = safeText.isEmpty() ? 0.0 : measurement.textWidth(safeStyle, safeText);
return new InlineTextToken(safeText, safeStyle, linkTarget, width,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.demcha.compose.engine.components.content.text.TextIndentStrategy;
import com.demcha.compose.engine.components.content.text.TextStyle;
import com.demcha.compose.engine.text.TextControlSanitizer;
import com.demcha.compose.engine.text.bidi.ArabicShaper;
import com.demcha.compose.engine.text.bidi.BidiParagraphResolver;
import com.demcha.compose.engine.components.style.Padding;
import com.demcha.compose.engine.measurement.TextMeasurementSystem;
Expand Down Expand Up @@ -610,21 +611,30 @@
double width = 0.0;
for (InlineRun run : node.inlineRuns()) {
if (run instanceof InlineTextRun textRun) {
width += measurement.textWidth(engineStyle, textRun.text());
width += measurement.textWidth(engineStyle, ArabicShaper.shape(textRun.text()));
} else if (run instanceof InlineImageRun imageRun) {
width += imageRun.width();
} else if (run instanceof InlineShapeRun shapeRun) {
width += shapeRun.width();
} else if (run instanceof InlineSvgRun svgRun) {
width += svgRun.width();
} else if (run instanceof InlineHighlightRun highlight) {
width += measurement.textWidth(engineStyle, highlight.text())
// Shaped, like the token this run becomes. An unshaped measurement
// sizes the text off one string and lays it out from another: the
// presentation forms carry their own advance widths, so the probe
// was answering about a string the layout never sees. Not currently
// observable end to end — auto-size does not shrink a paragraph
// holding a chip at all, for any script — but the two paths have to
// agree regardless of which of them is reached first.
// Shaped, like the token this run becomes: an unshaped measurement
// sizes the text off one string and lays it out from another.
width += measurement.textWidth(engineStyle, ArabicShaper.shape(highlight.text()))
+ highlight.background().padding().horizontal();
}
}
return width <= innerWidth;
}
List<String> lines = sanitizeLogicalLines(node.text());
List<String> lines = shapeAll(sanitizeLogicalLines(node.text()));
if (lines.size() != 1) {
return false;
}
Expand All @@ -638,7 +648,11 @@
double innerWidth,
TextMeasurementSystem measurement,
boolean markdownEnabled) {
List<String> logicalLines = sanitizeLogicalLines(node.text());
// Shaped before wrapping, because the contextual forms have their own advance
// widths and everything downstream — the fit tests, the spans, the page — must
// measure the text that will be drawn. Text without an Arabic letter passes
// through as the same instances.
List<String> logicalLines = shapeAll(sanitizeLogicalLines(node.text()));
boolean useMarkdownLayout = markdownEnabled && logicalLines.stream().anyMatch(ParagraphWrapping::containsMarkdownSyntax);
TextStyle textStyle = node.autoSize() != null
? toTextStyle(resolveAutoSizeTextStyle(node, innerWidth, measurement))
Expand Down Expand Up @@ -793,6 +807,21 @@
return ParagraphDirection.baseDirection(node);
}

private static List<String> shapeAll(List<String> logicalLines) {
List<String> shaped = null;
for (int index = 0; index < logicalLines.size(); index++) {
String line = logicalLines.get(index);
String shapedLine = ArabicShaper.shape(line);
if (shapedLine != line && shaped == null) {
shaped = new ArrayList<>(logicalLines);
}
if (shaped != null) {
shaped.set(index, shapedLine);
}
}
return shaped == null ? logicalLines : List.copyOf(shaped);
}

private static List<String> sanitizeLogicalLines(String rawText) {
String safeText = rawText == null ? "" : rawText.replace("\r\n", "\n").replace('\r', '\n');
String[] logicalLines = safeText.split("\n", -1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ public static String replace(String text, String replacement) {
* anything had a chance to act on it.</p>
*
* @param text source text
* @return sanitized text keeping the bidirectional formatting characters, never {@code null}
* <p>The Arabic joining controls (U+200C, U+200D) are kept for the same reason and
* read by the same kind of later seam: the shaper consumes them to decide whether
* two letters connect.</p>
*
* @return sanitized text keeping the formatting controls, never {@code null}
* @since 2.2.0
*/
public static String removeExceptDirectionMarks(String text) {
Expand All @@ -56,7 +60,7 @@ public static String removeExceptDirectionMarks(String text) {
for (int inputIndex = 0; inputIndex < text.length(); ) {
int codePoint = text.codePointAt(inputIndex);
int charCount = Character.charCount(codePoint);
if (isCategoryC(codePoint) && !isBidiControl(codePoint)) {
if (isCategoryC(codePoint) && !isFormattingControl(codePoint)) {
if (sanitized == null) {
sanitized = new StringBuilder(text.length());
sanitized.append(text, 0, inputIndex);
Expand Down Expand Up @@ -88,7 +92,7 @@ public static String removeDirectionMarks(String text) {
StringBuilder sanitized = null;
for (int index = 0; index < text.length(); index++) {
char character = text.charAt(index);
if (isBidiControl(character)) {
if (isFormattingControl(character)) {
if (sanitized == null) {
sanitized = new StringBuilder(text.length());
sanitized.append(text, 0, index);
Expand All @@ -109,6 +113,37 @@ public static String removeDirectionMarks(String text) {
* @return {@code true} for a bidirectional formatting character
* @since 2.2.0
*/
/**
* Returns whether a code point is an Arabic joining control — the zero-width
* non-joiner or joiner.
*
* <p>These are not bidirectional controls and are kept separate from them: they say
* nothing about direction, they say whether two letters may connect. U+200C forbids
* a join that would otherwise happen and U+200D forces one, which is the author's
* only way to write a form the contextual rules would not produce. Both draw
* nothing, and both are category C, so the plain control sanitizer deletes them —
* before the shaper that is their only reader has run.</p>
*
* @param codePoint code point to test
* @return {@code true} for U+200C or U+200D
* @since 2.2.0
*/
public static boolean isJoiningControl(int codePoint) {
return codePoint == 0x200C || codePoint == 0x200D;
}

/**
* Returns whether a code point steers the text pipeline and draws nothing — a
* bidirectional formatting character or an Arabic joining control.
*
* @param codePoint code point to test
* @return {@code true} for a formatting control the pipeline reads and then drops
* @since 2.2.0
*/
public static boolean isFormattingControl(int codePoint) {
return isBidiControl(codePoint) || isJoiningControl(codePoint);
}

public static boolean isBidiControl(int codePoint) {
return codePoint == 0x061C
|| codePoint == 0x200E
Expand Down
Loading
Loading