Skip to content

Commit f2ae964

Browse files
committed
Correctly handle RTL layout for text, list, table and document scope #86
1 parent d2264b5 commit f2ae964

File tree

9 files changed

+113
-15
lines changed

9 files changed

+113
-15
lines changed

src/Html2OpenXml/Expressions/BlockElementExpression.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,11 @@ protected override void ComposeStyles (ParsingContext context)
121121
}
122122

123123
// according to w3c, dir should be used in conjonction with lang. But whatever happens, we'll apply the RTL layout
124-
if ("rtl".Equals(node.Direction, StringComparison.OrdinalIgnoreCase))
125-
{
126-
paraProperties.Justification = new() { Val = JustificationValues.Right };
127-
}
128-
else if ("ltr".Equals(node.Direction, StringComparison.OrdinalIgnoreCase))
129-
{
130-
paraProperties.Justification = new() { Val = JustificationValues.Left };
124+
var dir = node.GetTextDirection();
125+
if (dir.HasValue) {
126+
paraProperties.BiDi = new() {
127+
Val = OnOffValue.FromBoolean(dir == AngleSharp.Dom.DirectionMode.Rtl)
128+
};
131129
}
132130

133131
var attrValue = styleAttributes!["text-align"];

src/Html2OpenXml/Expressions/BodyExpression.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ protected override void ComposeStyles(ParsingContext context)
5858
}
5959
}
6060
}
61+
62+
if (paraProperties.BiDi is not null)
63+
{
64+
var sectionProperties = context.MainPart.Document.Body!.GetFirstChild<SectionProperties>();
65+
if (sectionProperties == null || sectionProperties.GetFirstChild<PageSize>() == null)
66+
{
67+
context.MainPart.Document.Body.Append(sectionProperties = new());
68+
}
69+
70+
sectionProperties.AddChild(paraProperties.BiDi.CloneNode(true));
71+
}
6172
}
6273

6374
/// <summary>

src/Html2OpenXml/Expressions/ListExpression.cs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ namespace HtmlToOpenXml.Expressions;
2525
sealed class ListExpression(IHtmlElement node) : NumberingExpressionBase(node)
2626
{
2727
#if NET5_0_OR_GREATER
28-
readonly record struct ListContext(string Name, int AbsNumId, int InstanceId, int Level);
28+
readonly record struct ListContext(string Name, int AbsNumId, int InstanceId, int Level, DirectionMode? Dir);
2929
#else
30-
readonly struct ListContext(string listName, int absNumId, int instanceId, int level)
30+
readonly struct ListContext(string listName, int absNumId, int instanceId, int level, DirectionMode? dir)
3131
{
3232
public readonly string Name = listName;
3333
public readonly int AbsNumId = absNumId;
3434
public readonly int InstanceId = instanceId;
3535
public readonly int Level = level;
36+
public readonly DirectionMode? Dir = dir;
3637
}
3738
#endif
3839

@@ -64,8 +65,9 @@ public override IEnumerable<OpenXmlElement> Interpret(ParsingContext context)
6465
}
6566
else
6667
{
68+
var dir = node.GetTextDirection();
6769
listContext = new ListContext(listContext.Name, listContext.AbsNumId,
68-
listContext.InstanceId, listContext.Level + 1);
70+
listContext.InstanceId, listContext.Level + 1, dir ?? listContext.Dir);
6971
}
7072

7173
context.Properties("listContext", listContext);
@@ -85,6 +87,11 @@ public override IEnumerable<OpenXmlElement> Interpret(ParsingContext context)
8587
NumberingLevelReference = new() { Val = level - 1 },
8688
NumberingId = new() { Val = listContext.InstanceId }
8789
};
90+
if (listContext.Dir.HasValue) {
91+
p.ParagraphProperties.BiDi = new() {
92+
Val = OnOffValue.FromBoolean(listContext.Dir == DirectionMode.Rtl)
93+
};
94+
}
8895

8996
foreach (var child in childElements)
9097
yield return child;
@@ -103,11 +110,12 @@ private ListContext ConcretiseInstance(ParsingContext context, int abstractNumId
103110
var instanceId = GetListInstance(abstractNumId);
104111
int overrideLevelIndex = 0;
105112
var isOrderedTag = node.NodeName.Equals("ol", StringComparison.OrdinalIgnoreCase);
113+
var dir = node.GetTextDirection();
106114
if (!instanceId.HasValue || context.Converter.ContinueNumbering == false)
107115
{
108116
// create a new instance of that list template
109117
instanceId = IncrementInstanceId(context, abstractNumId, isReusable: context.Converter.ContinueNumbering);
110-
listContext = new ListContext(listStyle, abstractNumId, instanceId.Value, currentLevel + 1);
118+
listContext = new ListContext(listStyle, abstractNumId, instanceId.Value, currentLevel + 1, dir);
111119
}
112120
else
113121
// if the previous element is the same list style,
@@ -116,18 +124,18 @@ private ListContext ConcretiseInstance(ParsingContext context, int abstractNumId
116124
&& GetListName(precedingElement!) == listStyle)
117125
{
118126
instanceId = IncrementInstanceId(context, abstractNumId, isReusable: false);
119-
listContext = new ListContext(listStyle, abstractNumId, instanceId.Value, 1);
127+
listContext = new ListContext(listStyle, abstractNumId, instanceId.Value, 1, dir);
120128
}
121129
// be sure to restart to 1 any nested ordered list
122130
else if (currentLevel > 0 && isOrderedTag)
123131
{
124132
instanceId = IncrementInstanceId(context, abstractNumId, isReusable: false);
125133
overrideLevelIndex = currentLevel;
126-
listContext = new ListContext(listStyle, abstractNumId, instanceId.Value, currentLevel + 1);
134+
listContext = new ListContext(listStyle, abstractNumId, instanceId.Value, currentLevel + 1, dir);
127135
}
128136
else
129137
{
130-
return new ListContext(listStyle, abstractNumId, instanceId.Value, currentLevel + 1);
138+
return new ListContext(listStyle, abstractNumId, instanceId.Value, currentLevel + 1, dir);
131139
}
132140

133141
int startValue = 1;

src/Html2OpenXml/Expressions/PhrasingElementExpression.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ protected virtual void ComposeStyles (ParsingContext context)
9898
}
9999

100100
// according to w3c, dir should be used in conjonction with lang. But whatever happens, we'll apply the RTL layout
101-
if ("rtl".Equals(node.Direction, StringComparison.OrdinalIgnoreCase))
101+
var dir = node.GetTextDirection();
102+
if (dir == DirectionMode.Rtl)
102103
{
103104
runProperties.RightToLeftText = new RightToLeftText();
104105
}

src/Html2OpenXml/Expressions/Table/TableExpression.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ protected override void ComposeStyles (ParsingContext context)
208208
if (align.HasValue)
209209
tableProperties.TableJustification = new() { Val = align.Value.ToTableRowAlignment() };
210210

211+
var dir = node.GetTextDirection();
212+
if (dir.HasValue)
213+
tableProperties.BiDiVisual = new() {
214+
Val = dir == AngleSharp.Dom.DirectionMode.Rtl? OnOffOnlyValues.On : OnOffOnlyValues.Off
215+
};
216+
211217
var spacing = Convert.ToInt16(node.GetAttribute("cellspacing"));
212218
if (spacing > 0)
213219
tableProperties.TableCellSpacing = new() {

src/Html2OpenXml/Utilities/AngleSharpExtensions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ public static HtmlAttributeCollection GetStyles(this IElement element)
2929
return HtmlAttributeCollection.ParseStyle(element.GetAttribute("style"));
3030
}
3131

32+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
33+
public static DirectionMode? GetTextDirection(this IHtmlElement element)
34+
{
35+
if ("rtl".Equals(element.Direction, StringComparison.OrdinalIgnoreCase))
36+
return DirectionMode.Rtl;
37+
if ("ltr".Equals(element.Direction, StringComparison.OrdinalIgnoreCase))
38+
return DirectionMode.Ltr;
39+
return null;
40+
}
41+
3242
/// <summary>
3343
/// Gets whether the given child is preceded by any list element (<c>ol</c> or <c>ul</c>).
3444
/// </summary>

test/HtmlToOpenXml.Tests/BodyTests.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,18 @@ public bool PageOrientation_OverrideExistingLayout_ReturnsLandscapeDimension(str
4242
Assert.That(pageSize, Is.Not.Null);
4343
return pageSize.Height > pageSize.Width;
4444
}
45+
46+
[TestCase("rtl", ExpectedResult = true)]
47+
[TestCase("ltr", ExpectedResult = false)]
48+
[TestCase("", ExpectedResult = null)]
49+
public bool? WithRtl_ReturnsBidi_DocumentScoped(string dir)
50+
{
51+
var elements = converter.Parse($@"<body dir='{dir}'>Lorem</body>");
52+
Assert.That(elements, Has.Count.EqualTo(1));
53+
Assert.That(elements, Has.All.TypeOf<Paragraph>());
54+
55+
var bidi = mainPart.Document.Body!.GetFirstChild<SectionProperties>()?.GetFirstChild<BiDi>();
56+
return bidi?.Val?.Value;
57+
}
4558
}
4659
}

test/HtmlToOpenXml.Tests/DivTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,17 @@ public void PageBreakAfter_ReturnsTwoParagraphsThenOne()
8282
Assert.That(elements[1].LastChild?.HasChild<Break>(), Is.True);
8383
Assert.That(elements[1].LastChild?.HasChild<LastRenderedPageBreak>(), Is.False);
8484
}
85+
86+
[TestCase("rtl", ExpectedResult = true)]
87+
[TestCase("ltr", ExpectedResult = false)]
88+
[TestCase("", ExpectedResult = null)]
89+
public bool? WithRtl_ReturnsBidi(string dir)
90+
{
91+
var elements = converter.Parse($@"<div dir='{dir}'>Lorem</div>");
92+
Assert.That(elements, Has.Count.EqualTo(1));
93+
Assert.That(elements, Has.All.TypeOf<Paragraph>());
94+
var bidi = elements[0].GetFirstChild<ParagraphProperties>()?.BiDi;
95+
return bidi?.Val?.Value;
96+
}
8597
}
8698
}

test/HtmlToOpenXml.Tests/NumberingTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,5 +444,44 @@ public void NestedNumbering_ReturnsNestedList_RestartingOrder()
444444
Assert.That(instances.Last().GetFirstChild<LevelOverride>()?.LevelIndex?.Value, Is.EqualTo(1));
445445
Assert.That(instances.Last().GetFirstChild<LevelOverride>()?.StartOverrideNumberingValue?.Val?.Value, Is.EqualTo(1));
446446
}
447+
448+
[TestCase("rtl", true)]
449+
[TestCase("ltr", false)]
450+
[TestCase("", null)]
451+
public void WithRtl_ReturnsBidi(string dir, bool? expectedValue)
452+
{
453+
var elements = converter.Parse($@"<ol dir='{dir}'>
454+
<li>Item 1</li><li>Item 2</li>
455+
</ol>");
456+
457+
Assert.Multiple(() => {
458+
Assert.That(elements, Has.Count.EqualTo(2));
459+
Assert.That(elements, Is.All.TypeOf<Paragraph>());
460+
Assert.That(mainPart.NumberingDefinitionsPart?.Numbering, Is.Not.Null);
461+
});
462+
var bidis = elements.Cast<Paragraph>().Select(p => p.ParagraphProperties?.BiDi?.Val?.Value);
463+
Assert.That(bidis, Is.All.EqualTo(expectedValue));
464+
}
465+
466+
[TestCase("rtl", "rtl", ExpectedResult = true)]
467+
[TestCase("rtl", "ltr", ExpectedResult = false)]
468+
[TestCase("rtl", "", ExpectedResult = true)]
469+
[TestCase("", "rtl", ExpectedResult = true)]
470+
public bool? WithNestedRtl_ReturnsBidi(string dir, string nestedDir)
471+
{
472+
var elements = converter.Parse($@"<ol dir='{dir}'>
473+
<li>Item 1
474+
<ol dir='{nestedDir}'><li>Item 1.1</li></ol>
475+
</li>
476+
</ol>");
477+
478+
Assert.Multiple(() => {
479+
Assert.That(elements, Has.Count.EqualTo(2));
480+
Assert.That(elements, Is.All.TypeOf<Paragraph>());
481+
Assert.That(mainPart.NumberingDefinitionsPart?.Numbering, Is.Not.Null);
482+
});
483+
var bidi = elements.Last().GetFirstChild<ParagraphProperties>()?.BiDi;
484+
return bidi?.Val?.Value;
485+
}
447486
}
448487
}

0 commit comments

Comments
 (0)