Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ccdce63
feat(ios): add textAlignment
kacperzolkiewski Jan 29, 2026
9e157ed
feat: add parsing from html into input
kacperzolkiewski Jan 29, 2026
178c83d
feat: separate alignment logic to AlignmentUtils
kacperzolkiewski Jan 29, 2026
6823e42
fix: android build
kacperzolkiewski Jan 30, 2026
6640463
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski Feb 2, 2026
6d2bf85
fix: layout for ZWS
kacperzolkiewski Mar 10, 2026
7dc4737
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski Mar 11, 2026
51b54d5
fix: add alignment to Toolbar
kacperzolkiewski Mar 11, 2026
a0e9f64
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski Mar 25, 2026
d978723
fix: review changes
kacperzolkiewski Mar 26, 2026
962b042
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski Apr 30, 2026
161ff8c
fix: resolve conflicts in parser
kacperzolkiewski Apr 30, 2026
e62df66
fix: alignment after mataatributes remake
kacperzolkiewski May 6, 2026
e21de1f
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski May 6, 2026
869ce6d
fix: refactor alignment to style
kacperzolkiewski May 7, 2026
76734f3
fix: removing checkboxList
kacperzolkiewski May 7, 2026
e7d5b8e
fix: preserve aignment during typing attributes reset
kacperzolkiewski May 7, 2026
bea7294
fix: alignment detection
kacperzolkiewski May 8, 2026
521fdc2
fix: remove unused code
kacperzolkiewski May 8, 2026
b09f68b
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski May 8, 2026
aef3a57
fix: cursor alignment for empty lines
kacperzolkiewski May 11, 2026
98d7873
fix: placeholder alignment
kacperzolkiewski May 11, 2026
8f81868
fix: linting
kacperzolkiewski May 11, 2026
3c8b79e
test: add e2e tests for alignment
kacperzolkiewski May 11, 2026
9e6b747
Merge branch 'main' into @kacperzolkiewski/text-alignment
kacperzolkiewski May 12, 2026
de44d13
feat: add text alignment for paragraph
kacperzolkiewski May 13, 2026
fbd5133
feat(android): add alignment for paragraph styles
kacperzolkiewski May 20, 2026
56caa73
Merge branch 'main' into @kacperzolkiewski/text-alignment-android
kacperzolkiewski May 20, 2026
7375256
fix: use getSafeSpanBoundaries
kacperzolkiewski May 20, 2026
dec9280
fix: remove unused files
kacperzolkiewski May 20, 2026
61d5a73
fix: remove ios only from docs
kacperzolkiewski May 20, 2026
3d4a10c
docs: add info about justify
kacperzolkiewski May 20, 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
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
appId: swmansion.enriched.example
tags:
- ios-only
---
# Visually validates text alignment rendering
- launchApp
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package com.swmansion.enriched.common.parser;

import android.text.Editable;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.AlignmentSpan;
import android.text.style.ParagraphStyle;
import com.swmansion.enriched.common.EnrichedConstants;
import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan;
import com.swmansion.enriched.common.spans.EnrichedBoldSpan;
import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan;
import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan;
Expand All @@ -35,6 +34,8 @@
import java.io.StringReader;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.ccil.cowan.tagsoup.HTMLSchema;
import org.ccil.cowan.tagsoup.Parser;
import org.xml.sax.Attributes;
Expand Down Expand Up @@ -86,8 +87,8 @@ public static String toHtml(Spanned text) {
String normalizedBlockQuote =
normalizedCodeBlock.replaceAll("</blockquote>\\n<br>", "</blockquote>");

// replace empty <p></p> into <br> in the very end
String normalizedHtml = normalizedBlockQuote.replaceAll("<p></p>", "<br>");
// Replace empty <p> tags (with or without style attributes) with <br>
String normalizedHtml = normalizedBlockQuote.replaceAll("<p[^>]*></p>", "<br>");

return "<html>\n" + normalizedHtml + "</html>";
}
Expand Down Expand Up @@ -136,6 +137,14 @@ private static void withinDiv(StringBuilder out, Spanned text, int start, int en
}
}

private static String getAlignmentStyleAttr(Spanned text, int start, int end) {
EnrichedAlignmentSpan[] spans = text.getSpans(start, end, EnrichedAlignmentSpan.class);
if (spans.length == 0) return "";
String cssValue = spans[0].getCssValue();
if (cssValue.equals("auto")) return "";
return " style=\"text-align: " + cssValue + "\"";
}

private static String getBlockTag(EnrichedParagraphSpan[] spans) {
for (EnrichedParagraphSpan span : spans) {
if (span instanceof EnrichedUnorderedListSpan) {
Expand Down Expand Up @@ -213,15 +222,17 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int
if (isUlListItem && !isInUlList) {
// Current paragraph is the first item in a list
isInUlList = true;
out.append("<ul").append(">\n");
out.append("<ul").append(getAlignmentStyleAttr(text, i, next)).append(">\n");
} else if (isOlListItem && !isInOlList) {
// Current paragraph is the first item in a list
isInOlList = true;
out.append("<ol").append(">\n");
out.append("<ol").append(getAlignmentStyleAttr(text, i, next)).append(">\n");
} else if (isCheckboxListItem && !isInCheckboxList) {
// Current paragraph is the first item in a list
isInCheckboxList = true;
out.append("<ul data-type=\"checkbox\">\n");
out.append("<ul data-type=\"checkbox\"")
.append(getAlignmentStyleAttr(text, i, next))
.append(">\n");
}

boolean isList = isUlListItem || isOlListItem || isCheckboxListItem;
Expand All @@ -230,6 +241,11 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int
out.append("<");
out.append(tagType);

// Add alignment style to non-list paragraph/heading tags
if (!isList) {
out.append(getAlignmentStyleAttr(text, i, next));
}

if (isCheckboxListItem) {
EnrichedCheckboxListSpan[] checkboxSpans =
text.getSpans(i, next, EnrichedCheckboxListSpan.class);
Expand Down Expand Up @@ -395,6 +411,24 @@ class HtmlToSpannedConverter<T> implements ContentHandler {
private static Boolean isInOrderedList = false;
private static Boolean isInCheckboxList = false;
private static Boolean isEmptyTag = false;
private static String currentListAlignmentCssValue = null;

private static final Pattern CSS_ALIGNMENT_PATTERN =
Pattern.compile("text-align\\s*:\\s*(left|center|right)", Pattern.CASE_INSENSITIVE);

private static String parseCssAlignmentValue(Attributes attributes) {
String style = attributes.getValue("", "style");
if (style == null) return null;
Matcher m = CSS_ALIGNMENT_PATTERN.matcher(style);
return m.find() ? m.group(1).toLowerCase() : null;
}

private static void pushAlignmentMark(Editable text, Attributes attributes) {
String cssValue = parseCssAlignmentValue(attributes);
if (cssValue != null) {
start(text, new Alignment(cssValue));
}
}

public HtmlToSpannedConverter(
String source, T style, Parser parser, EnrichedSpanFactory<T> spanFactory) {
Expand Down Expand Up @@ -448,9 +482,23 @@ public Spanned convert() {
int end = mSpannableStringBuilder.getSpanEnd(zeroWidthSpaceSpan);

if (mSpannableStringBuilder.charAt(start) != EnrichedConstants.ZWS) {
// Insert zero-width space character at the start if it's not already present.
// Collect spans before inserting ZWS. SPAN_EXCLUSIVE_EXCLUSIVE spans will
// shift to start+1. We must re-anchor them back to `start` to prevent
// the loop from processing them again and inserting duplicate ZWS.
Object[] colocated = mSpannableStringBuilder.getSpans(start, start + 1, Object.class);

mSpannableStringBuilder.insert(start, EnrichedConstants.ZWS_STRING);
end++; // Adjust end position due to insertion.
end++;

for (Object span : colocated) {
if (span == zeroWidthSpaceSpan) continue;
// Only re-anchor spans that actually shifted.
// Skip overlapping or INCLUSIVE spans that kept their original start.
if (mSpannableStringBuilder.getSpanStart(span) != start + 1) continue;
int spanEnd = mSpannableStringBuilder.getSpanEnd(span);
mSpannableStringBuilder.removeSpan(span);
mSpannableStringBuilder.setSpan(span, start, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}

mSpannableStringBuilder.removeSpan(zeroWidthSpaceSpan);
Expand All @@ -468,14 +516,17 @@ private void handleStartTag(String tag, Attributes attributes) {
} else if (tag.equalsIgnoreCase("p")) {
isEmptyTag = true;
startBlockElement(mSpannableStringBuilder);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("ul")) {
isInOrderedList = false;
String dataType = attributes.getValue("", "data-type");
isInCheckboxList = "checkbox".equals(dataType);
currentListAlignmentCssValue = parseCssAlignmentValue(attributes);
startBlockElement(mSpannableStringBuilder);
} else if (tag.equalsIgnoreCase("ol")) {
isInOrderedList = true;
currentOrderedListItemIndex = 0;
currentListAlignmentCssValue = parseCssAlignmentValue(attributes);
startBlockElement(mSpannableStringBuilder);
} else if (tag.equalsIgnoreCase("li")) {
isEmptyTag = true;
Expand All @@ -500,16 +551,22 @@ private void handleStartTag(String tag, Attributes attributes) {
start(mSpannableStringBuilder, new Strikethrough());
} else if (tag.equalsIgnoreCase("h1")) {
startHeading(mSpannableStringBuilder, 1);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("h2")) {
startHeading(mSpannableStringBuilder, 2);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("h3")) {
startHeading(mSpannableStringBuilder, 3);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("h4")) {
startHeading(mSpannableStringBuilder, 4);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("h5")) {
startHeading(mSpannableStringBuilder, 5);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("h6")) {
startHeading(mSpannableStringBuilder, 6);
pushAlignmentMark(mSpannableStringBuilder, attributes);
} else if (tag.equalsIgnoreCase("img")) {
// Image content means the current tag is not empty (e.g. <li><img .../></li>).
isEmptyTag = false;
Expand All @@ -525,9 +582,13 @@ private void handleEndTag(String tag) {
if (tag.equalsIgnoreCase("br")) {
handleBr(mSpannableStringBuilder);
} else if (tag.equalsIgnoreCase("p")) {
endBlockElement(mSpannableStringBuilder);
endBlockElement(mSpannableStringBuilder, mSpanFactory);
} else if (tag.equalsIgnoreCase("ul")) {
endBlockElement(mSpannableStringBuilder);
currentListAlignmentCssValue = null;
endBlockElement(mSpannableStringBuilder, mSpanFactory);
} else if (tag.equalsIgnoreCase("ol")) {
currentListAlignmentCssValue = null;
endBlockElement(mSpannableStringBuilder, mSpanFactory);
} else if (tag.equalsIgnoreCase("li")) {
endLi(mSpannableStringBuilder, mStyle, mSpanFactory);
} else if (tag.equalsIgnoreCase("b")) {
Expand Down Expand Up @@ -585,15 +646,15 @@ private static void startBlockElement(Editable text) {
start(text, new Newline(1));
}

private static void endBlockElement(Editable text) {
private static <T> void endBlockElement(Editable text, EnrichedSpanFactory<T> spanFactory) {
Newline n = getLast(text, Newline.class);
if (n != null) {
appendNewlines(text, n.mNumNewlines);
text.removeSpan(n);
}
Alignment a = getLast(text, Alignment.class);
if (a != null) {
setSpanFromMark(text, a, new AlignmentSpan.Standard(a.mAlignment));
setParagraphSpanFromMark(text, a, spanFactory.createAlignmentSpan(a.mCssValue));
}
}

Expand All @@ -604,6 +665,10 @@ private static void handleBr(Editable text) {
private void startLi(Editable text, Attributes attributes) {
startBlockElement(text);

if (currentListAlignmentCssValue != null) {
start(text, new Alignment(currentListAlignmentCssValue));
}

if (isInOrderedList) {
currentOrderedListItemIndex++;
start(text, new List("ordered", currentOrderedListItemIndex, false));
Expand All @@ -616,7 +681,7 @@ private void startLi(Editable text, Attributes attributes) {
}

private static <T> void endLi(Editable text, T style, EnrichedSpanFactory<T> spanFactory) {
endBlockElement(text);
endBlockElement(text, spanFactory);

List l = getLast(text, List.class);
if (l != null) {
Expand All @@ -629,7 +694,7 @@ private static <T> void endLi(Editable text, T style, EnrichedSpanFactory<T> spa
}
}

endBlockElement(text);
endBlockElement(text, spanFactory);
}

private void startBlockquote(Editable text) {
Expand All @@ -639,7 +704,7 @@ private void startBlockquote(Editable text) {

private static <T> void endBlockquote(
Editable text, T style, EnrichedSpanFactory<T> spanFactory) {
endBlockElement(text);
endBlockElement(text, spanFactory);
Blockquote last = getLast(text, Blockquote.class);
setParagraphSpanFromMark(text, last, spanFactory.createBlockQuoteSpan(style));
}
Expand All @@ -650,7 +715,7 @@ private void startCodeBlock(Editable text) {
}

private static <T> void endCodeBlock(Editable text, T style, EnrichedSpanFactory<T> spanFactory) {
endBlockElement(text);
endBlockElement(text, spanFactory);
CodeBlock last = getLast(text, CodeBlock.class);
setParagraphSpanFromMark(text, last, spanFactory.createCodeBlockSpan(style));
}
Expand Down Expand Up @@ -684,7 +749,7 @@ private void startHeading(Editable text, int level) {

private static <T> void endHeading(
Editable text, T style, EnrichedSpanFactory<T> spanFactory, int level) {
endBlockElement(text);
endBlockElement(text, spanFactory);

switch (level) {
case 1:
Expand Down Expand Up @@ -958,10 +1023,10 @@ public Newline(int numNewlines) {
}

private static class Alignment {
private final Layout.Alignment mAlignment;
final String mCssValue;

public Alignment(Layout.Alignment alignment) {
mAlignment = alignment;
public Alignment(String cssValue) {
this.mCssValue = cssValue;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.swmansion.enriched.common.parser

import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan
import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan
import com.swmansion.enriched.common.spans.EnrichedBoldSpan
import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan
Expand All @@ -21,6 +22,8 @@ import com.swmansion.enriched.common.spans.EnrichedUnderlineSpan
import com.swmansion.enriched.common.spans.EnrichedUnorderedListSpan

interface EnrichedSpanFactory<T> {
fun createAlignmentSpan(cssValue: String): EnrichedAlignmentSpan

fun createBoldSpan(style: T): EnrichedBoldSpan

fun createItalicSpan(style: T): EnrichedItalicSpan
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.swmansion.enriched.common.spans

import android.text.Layout
import android.text.style.AlignmentSpan
import com.swmansion.enriched.common.spans.interfaces.EnrichedZeroWidthSpaceSpan

open class EnrichedAlignmentSpan(
val cssValue: String,
) : AlignmentSpan.Standard(cssValueToLayoutAlignment(cssValue)),
EnrichedZeroWidthSpaceSpan {
companion object {
fun cssValueToLayoutAlignment(cssValue: String): Layout.Alignment =
when (cssValue) {
"center" -> Layout.Alignment.ALIGN_CENTER
"right" -> Layout.Alignment.ALIGN_OPPOSITE
else -> Layout.Alignment.ALIGN_NORMAL
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.swmansion.enriched.text

import com.swmansion.enriched.common.parser.EnrichedSpanFactory
import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan
import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan
import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan
import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan
import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan
Expand All @@ -22,6 +24,8 @@ import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan
import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan

class EnrichedTextSpanFactory : EnrichedSpanFactory<EnrichedTextStyle> {
override fun createAlignmentSpan(cssValue: String) = EnrichedTextAlignmentSpan(cssValue)

override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style)

override fun createItalicSpan(style: EnrichedTextStyle) = EnrichedTextItalicSpan(style)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.swmansion.enriched.text.spans

import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan
import com.swmansion.enriched.text.EnrichedTextStyle
import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan

class EnrichedTextAlignmentSpan(
cssValue: String,
) : EnrichedAlignmentSpan(cssValue),
EnrichedTextSpan {
override val dependsOnHtmlStyle = false

override fun rebuildWithStyle(style: EnrichedTextStyle) = this
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.swmansion.enriched.textinput

import com.swmansion.enriched.common.parser.EnrichedSpanFactory
import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan
import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan
import com.swmansion.enriched.common.spans.EnrichedImageSpan
import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan
import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan
import com.swmansion.enriched.textinput.spans.EnrichedInputBoldSpan
import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan
Expand All @@ -25,6 +27,8 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan
import com.swmansion.enriched.textinput.styles.HtmlStyle

class EnrichedTextInputSpannableFactory : EnrichedSpanFactory<HtmlStyle> {
override fun createAlignmentSpan(cssValue: String) = EnrichedInputAlignmentSpan(cssValue)

override fun createBoldSpan(style: HtmlStyle) = EnrichedInputBoldSpan(style)

override fun createItalicSpan(style: HtmlStyle) = EnrichedInputItalicSpan(style)
Expand Down
Loading
Loading