Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4f42e95
feat(api): let a paragraph declare its writing direction
DemchaAV Aug 10, 2026
b8d700e
feat(engine): lay out a line as one span per directional run
DemchaAV Aug 10, 2026
edbe5f4
feat(engine): draw a right-to-left line the way it is read
DemchaAV Aug 10, 2026
dbaf319
feat(api): let AUTO decide the alignment it implies
DemchaAV Aug 10, 2026
0f85644
refactor(engine): give the three backends one line-start expression
DemchaAV Aug 10, 2026
3a50b4f
feat(engine): carry direction through the inline-run path
DemchaAV Aug 10, 2026
c9f9154
feat(engine): finish direction across the wrap paths and the slide ba…
DemchaAV Aug 10, 2026
ac5c639
feat(docx): tell Word which way a paragraph runs
DemchaAV Aug 10, 2026
58cf307
docs(changelog): record the writing-direction work for 2.2.0
DemchaAV Aug 10, 2026
80c14ac
docs(examples): show what each writing direction does to a paragraph
DemchaAV Aug 10, 2026
34549b3
docs(recipes): explain writing direction and when to reach for it
DemchaAV Aug 10, 2026
ba4c70d
perf(engine): stop paying for direction on lines that have none
DemchaAV Aug 10, 2026
d483d3d
fix(engine): split spans where direction changes and fix AUTO to one …
DemchaAV Aug 10, 2026
4adfce1
test(engine): pin the bidi seams that nothing was holding
DemchaAV Aug 10, 2026
73bd9dd
fix(engine): make the escape comment in the bidi scan true
DemchaAV Aug 10, 2026
ba4a9b0
docs(examples): publish the text-direction preview
DemchaAV Aug 10, 2026
20c7bac
fix(engine): read a paragraph's direction in one place
DemchaAV Aug 11, 2026
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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ follow semantic versioning; release dates are ISO 8601.

## v2.2.0 — Planned

### Public API

- **A paragraph can say which way it runs.** `ParagraphBuilder.direction(...)` takes
`TextDirection.LTR`, `RTL`, or `AUTO`, which reads the direction off the first strong
character. Hebrew and Arabic were previously laid out and drawn in logical order — the
order text is read in, not the order a page draws it — so every line came out reversed
in a document that otherwise looked finished.

Direction is a separate choice from `TextAlign`: alignment says where a line sits,
direction says which way it runs. They meet in one place, so a right-to-left paragraph
aligns right unless the caller chose an alignment of their own.

Lines are resolved with the Unicode Bidirectional Algorithm, so a Latin word or a
number embedded in Hebrew keeps running forwards, and the paragraph direction only
decides what it is embedded in. A line with no right-to-left character resolves to
itself without the algorithm running at all, so existing documents take the path they
always took — held to that by the layout snapshots and visual baselines, none of which
moved.

All three wrap paths carry it: plain text, inline runs (what templates author
through), and markdown. Each backend does what it must and no more — the PDF backend
reverses a right-to-left run, because a PDF draws characters in the order it is given
them; PowerPoint and Word have their own bidirectional engines, so the text reaches
them in logical order and a right-to-left paragraph is marked rather than rewritten.

The bidirectional formatting characters (`U+200E`, `U+200F`, `U+061C` and the
embeddings and isolates) now survive control-character sanitizing until the algorithm
has read them. They are what an author uses to steer a neutral stretch of text, and
removing them with the rest of Unicode category C deleted the instruction before
anything could act on it. They draw nothing, so they are dropped again at the seam
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
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.

### Fonts

- **Bundled families for Arabic and Hebrew.** `FontName.AMIRI` and
Expand Down
Binary file added assets/readme/examples/text-direction.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import com.demcha.compose.document.node.InlineTextRun;
import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.layout.ParagraphDirection;
import com.demcha.compose.document.node.TextDirection;
import com.demcha.compose.document.node.TextVerticalAlign;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
Expand All @@ -43,6 +45,8 @@ public final class ParagraphBuilder {
private final List<InlineRun> inlineRuns = new ArrayList<>();
private DocumentTextStyle textStyle = DocumentTextStyle.DEFAULT;
private TextAlign align = TextAlign.LEFT;
private boolean alignChosenByCaller;
private TextDirection direction = TextDirection.LTR;
private double lineSpacing = 0.0;
private String bulletOffset = "";
private DocumentTextIndent indentStrategy = DocumentTextIndent.NONE;
Expand Down Expand Up @@ -102,6 +106,24 @@ public ParagraphBuilder textStyle(DocumentTextStyle textStyle) {
*/
public ParagraphBuilder align(TextAlign align) {
this.align = align == null ? TextAlign.LEFT : align;
this.alignChosenByCaller = true;
return this;
}

/**
* Sets the writing direction of the paragraph.
*
* <p>A {@link TextDirection#RTL} or first-strong-resolved {@link TextDirection#AUTO}
* paragraph aligns to the right, because that is the edge a right-to-left line starts
* from. Calling {@link #align(TextAlign)} overrides that — the caller's alignment is
* never second-guessed.</p>
*
* @param direction writing direction; {@code null} restores {@link TextDirection#LTR}
* @return this builder
* @since 2.2.0
*/
public ParagraphBuilder direction(TextDirection direction) {
this.direction = direction == null ? TextDirection.LTR : direction;
return this;
}

Expand Down Expand Up @@ -1041,7 +1063,7 @@ public ParagraphNode build() {
text,
List.copyOf(inlineRuns),
textStyle,
align,
resolveAlign(),
lineSpacing,
bulletOffset,
indentStrategy,
Expand All @@ -1051,7 +1073,47 @@ public ParagraphNode build() {
margin,
autoSize,
verticalAlign,
anchor);
anchor,
direction);
}

/**
* A right-to-left paragraph starts at the right edge, so that is where its lines sit
* unless the caller said otherwise.
*
* <p>{@link TextDirection#AUTO} is decided the same way the bidirectional algorithm
* decides it — by the first strong character — which {@link Character} answers
* without the engine. Resolving it here rather than during layout is what keeps
* "the caller did not choose an alignment" distinguishable from "the caller chose
* LEFT": the node carries a concrete alignment, and that distinction lives only in
* this builder.</p>
*/
private TextAlign resolveAlign() {
if (alignChosenByCaller || direction == TextDirection.LTR) {
return align;
}
// Asked of the same resolver the layout asks, rather than answered again here.
// The paragraph has to sit at the edge it is laid out from, and two readings of
// "which way does this run" are two chances to pick different edges.
// Asked of the same resolver the layout asks, rather than answered again here.
// The paragraph has to sit at the edge it is laid out from, and two readings of
// "which way does this run" are two chances to pick different edges.
String probe = text.isBlank() ? inlineRunText() : text;
return ParagraphDirection.resolve(probe, direction) == TextDirection.RTL
? TextAlign.RIGHT
: align;
}

private String inlineRunText() {
StringBuilder concatenated = new StringBuilder();
for (InlineRun run : inlineRuns) {
if (run instanceof InlineTextRun textRun) {
concatenated.append(textRun.text());
} else if (run instanceof InlineHighlightRun highlight) {
concatenated.append(highlight.text());
}
}
return concatenated.toString();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.demcha.compose.document.layout;

import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.TextDirection;
import com.demcha.compose.engine.text.bidi.BidiParagraphResolver;

/**
* Answers which way a paragraph actually runs.
*
* <p>{@link TextDirection#AUTO} is a question, not an answer: it says to read the
* direction off the first strong character. Everything downstream needs the answer —
* the layout to order the line, Word to be told the paragraph's base direction, the
* builder to know which edge an unaligned paragraph sits at — and every one of them
* asking separately is how they come to disagree.</p>
*
* <p>They did. The alignment default was resolved with a hand-rolled scan for the first
* character of strong directionality, while the layout asked {@code java.text.Bidi}.
* The two agree on ordinary text and part company on <em>isolates</em>: UAX&nbsp;#9 rule
* P2 skips the characters between an isolate initiator and its matching PDI when looking
* for the first strong character, and a plain scan reads straight into them. So
* {@code LRI + Hebrew + PDI + "hello"} resolved right-to-left for the alignment and
* left-to-right for the layout — a paragraph aligned to one edge and laid out from the
* other. Isolates are the documented way to steer a neutral stretch of text and this
* release is the one that started carrying them through sanitizing, so the disagreement
* was reachable by exactly the author who read the documentation.</p>
*
* <p>This class is that single answer. It lives in the layout package because that is
* the sanctioned bridge from the canonical surface to the engine: the builder may call
* here, and only here does the call reach {@code BidiParagraphResolver}.</p>
*
* <p>Ownership: layout bridge.</p>
*
* @since 2.2.0
*/
public final class ParagraphDirection {

private ParagraphDirection() {
}

/**
* Resolves the direction a paragraph runs in, never returning {@link TextDirection#AUTO}.
*
* @param node paragraph to read
* @return {@link TextDirection#RTL} or {@link TextDirection#LTR}
*/
public static TextDirection resolve(ParagraphNode node) {
return resolve(node.text(), node.direction());
}

/**
* Resolves the direction declared text runs in, never returning {@link TextDirection#AUTO}.
*
* <p>The text form exists for the builder, which has to answer this question before
* there is a node to ask about.</p>
*
* @param text the paragraph's text in logical order
* @param declared the direction the caller asked for
* @return {@link TextDirection#RTL} or {@link TextDirection#LTR}
*/
public static TextDirection resolve(String text, TextDirection declared) {
if (declared == TextDirection.RTL) {
return TextDirection.RTL;
}
if (declared != TextDirection.AUTO) {
return TextDirection.LTR;
}
int baseLevel = BidiParagraphResolver.baseLevel(
text, BidiParagraphResolver.BaseDirection.FIRST_STRONG_CHARACTER);
return BidiParagraphResolver.isRightToLeftLevel(baseLevel)
? TextDirection.RTL
: TextDirection.LTR;
}

/**
* Maps the resolved direction onto the engine's base direction.
*
* @param node paragraph to read
* @return base direction for the bidirectional algorithm
*/
public static BidiParagraphResolver.BaseDirection baseDirection(ParagraphNode node) {
return resolve(node) == TextDirection.RTL
? BidiParagraphResolver.BaseDirection.RIGHT_TO_LEFT
: BidiParagraphResolver.BaseDirection.LEFT_TO_RIGHT;
}
}
Loading
Loading