Skip to content
Merged
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
12 changes: 11 additions & 1 deletion TriasDev.Templify.Tests/Helpers/OdtDocumentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public sealed class OdtDocumentBuilder
private readonly StringBuilder _footer = new StringBuilder();
private readonly List<(string Name, byte[] Data)> _extraEntries = new List<(string, byte[])>();
private string _automaticStyles = string.Empty;
private string _commonStyles = string.Empty;
private bool _isTemplate;
private bool _mimetypeFirst = true;
private bool _includeMimetype = true;
Expand Down Expand Up @@ -126,6 +127,13 @@ public OdtDocumentBuilder AddAutomaticStyles(string xml)
return this;
}

/// <summary>Adds common (named) styles (raw XML) to the office:styles of styles.xml.</summary>
public OdtDocumentBuilder AddCommonStyles(string xml)
{
_commonStyles += xml;
return this;
}

/// <summary>Adds an extra package entry (listed in the manifest).</summary>
public OdtDocumentBuilder AddEntry(string name, byte[] data)
{
Expand Down Expand Up @@ -176,7 +184,9 @@ public string BuildStylesXml()
string footer = _footer.Length > 0 ? $"<style:footer>{_footer}</style:footer>" : string.Empty;
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
$"<office:document-styles {NamespaceDeclarations} office:version=\"1.3\">" +
"<office:styles><style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/></office:styles>" +
"<office:styles><style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>" +
_commonStyles +
"</office:styles>" +
"<office:automatic-styles><style:page-layout style:name=\"pm1\"/></office:automatic-styles>" +
"<office:master-styles>" +
$"<style:master-page style:name=\"Standard\" style:page-layout-name=\"pm1\">{header}{footer}</style:master-page>" +
Expand Down
19 changes: 19 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtLibreOfficeRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,25 @@ public void HeaderRegions_ProcessedDocumentOpensInLibreOffice()
Assert.StartsWith("%PDF", Encoding.ASCII.GetString(pdf, 0, 4), StringComparison.Ordinal);
}

[Fact]
public void MarkdownStyle_DoesNotShadowACommonStyle_InLibreOffice()
{
LibreOfficeRunner.RequireExecutable();

OdtDocumentBuilder template = new OdtDocumentBuilder()
.AddCommonStyles("<style:style style:name=\"T3\" style:family=\"text\"><style:text-properties fo:color=\"#ff0000\"/></style:style>")
.AddXml("<text:p><text:span text:style-name=\"T3\">red</text:span> {{Bold}}</text:p>");

byte[] output = ProcessToBytes(template, new Dictionary<string, object> { ["Bold"] = "**b**" });
OdtDocumentVerifier resaved = new OdtDocumentVerifier(LibreOfficeRunner.Convert(output, "odt", "odt", "odt"));

// LibreOffice still reads the red text with the common style, and only the markdown text as bold.
System.Xml.Linq.XElement red = resaved.Body.Descendants(OdtDocumentVerifier.Text + "span").Single(s => s.Value == "red");
Assert.Equal("T3", (string?)red.Attribute(OdtDocumentVerifier.Text + "style-name"));
Assert.Null(GetEffectiveProperty(resaved, "red", OdtDocumentVerifier.Fo + "font-weight"));
Assert.Equal("bold", GetEffectiveProperty(resaved, "b", OdtDocumentVerifier.Fo + "font-weight"));
}

private static string? GetEffectiveProperty(OdtDocumentVerifier document, string text, System.Xml.Linq.XName property)
{
System.Xml.Linq.XElement? automaticStyles = document.ContentXml.Root!.Element(OdtDocumentVerifier.Office + "automatic-styles");
Expand Down
32 changes: 32 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtMarkdownTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,38 @@ public void MarkdownInHeader_CreatesStyleInStylesXml()
Assert.NotNull(TextProperties(output.ContentXml, bodySpan));
}

[Fact]
public void NewStyleName_DoesNotShadowACommonStyleOfStylesXml()
{
// content.xml spans can reference common (named) styles of styles.xml. An automatic style with the same
// name takes precedence in LibreOffice, so the red "T3" text used to turn bold when markdown created T3.
(_, OdtDocumentVerifier output) = OdtTestHelper.Process(
new OdtDocumentBuilder()
.AddCommonStyles("<style:style style:name=\"T3\" style:family=\"text\"><style:text-properties fo:color=\"#ff0000\"/></style:style>"
+ "<text:list-style style:name=\"T4\"/>")
.AddXml("<text:p><text:span text:style-name=\"T3\">red</text:span> {{Bold}}</text:p>"),
new Dictionary<string, object> { ["Bold"] = "**b**" });

XElement bold = output.Body.Descendants(_text + "span").Single(s => s.Value == "b");
Assert.Equal("T5", (string?)bold.Attribute(_text + "style-name"));
Assert.Equal("bold", (string?)TextProperties(output.ContentXml, bold)!.Attribute(_fo + "font-weight"));
XElement red = output.Body.Descendants(_text + "span").Single(s => s.Value == "red");
Assert.Equal("T3", (string?)red.Attribute(_text + "style-name"));
Assert.Null(TextProperties(output.ContentXml, red));
}

[Fact]
public void HeaderStyles_MayReuseNamesOfContentStyles()
{
// Automatic styles are per part: a header style T1 in styles.xml and the content style T1 do not collide.
(_, OdtDocumentVerifier output) = OdtTestHelper.Process(
new OdtDocumentBuilder().AddHeaderParagraph("{{Value}}"),
new Dictionary<string, object> { ["Value"] = "~~s~~" });

XElement headerSpan = output.StylesXml!.Descendants(_text + "span").Single();
Assert.Equal("T1", (string?)headerSpan.Attribute(_text + "style-name"));
}

[Fact]
public void MarkdownInLoop_ReusesStyleForAllItems()
{
Expand Down
3 changes: 3 additions & 0 deletions TriasDev.Templify/OpenDocument/OdtPlaceholderProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public OdtPlaceholderProcessor(
_warningCollector = warningCollector ?? throw new ArgumentNullException(nameof(warningCollector));
}

/// <summary>Gets the automatic text styles created for markdown formatting.</summary>
public OdtTextStyles Styles => _styles;

/// <summary>Gets the number of placeholders replaced so far.</summary>
public int ReplacementCount { get; private set; }

Expand Down
9 changes: 8 additions & 1 deletion TriasDev.Templify/OpenDocument/OdtTemplateEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,16 @@ public void Process(OdtPackage package, IEvaluationContext context)
throw new InvalidOdtPackageException("Invalid document: content.xml has no text body (office:text).");
}

XDocument? styles = package.GetXml(OdtPackage.StylesEntry);

// content.xml can reference the common styles of styles.xml by name; markdown styles must not shadow them.
_placeholders.Styles.ReserveNames(
styles?.Root?.Elements(OdfNames.Office + "styles").Descendants()
.Select(e => (string?)e.Attribute(OdfNames.Style + "name")).OfType<string>()
?? Enumerable.Empty<string>());

ProcessBlocks(body.Elements().ToList(), context);

XDocument? styles = package.GetXml(OdtPackage.StylesEntry);
List<XElement> headersAndFooters = styles?.Root != null
? GetHeadersAndFooters(styles.Root).ToList()
: new List<XElement>();
Expand Down
15 changes: 12 additions & 3 deletions TriasDev.Templify/OpenDocument/OdtTextStyles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ namespace TriasDev.Templify.OpenDocument;
/// <para>
/// A style is reused when the part already has an automatic text style with exactly the markdown
/// properties (e.g. one created by an earlier replacement, or an identical one written by LibreOffice).
/// New styles are named <c>T{n}</c> with the lowest number not used by any style of the part.
/// New styles are named <c>T{n}</c> with the lowest number not used by any style of the part, nor by a
/// reserved name: the common styles of <c>styles.xml</c> are visible from <c>content.xml</c>, and a span
/// referencing a common style <c>T3</c> would take the formatting of an automatic style of the same name.
/// </para>
/// <para>
/// The formatted span is nested inside the span holding the placeholder; ODF combines the properties
Expand All @@ -35,6 +37,12 @@ internal sealed class OdtTextStyles
private static readonly XName _textProperties = OdfNames.Style + "text-properties";

private readonly Dictionary<(XDocument Part, int Key), string> _cache = new Dictionary<(XDocument, int), string>();
private readonly HashSet<string> _reservedNames = new HashSet<string>(StringComparer.Ordinal);

/// <summary>
/// Reserves style names that new styles must not take (the names of the common styles of <c>styles.xml</c>).
/// </summary>
public void ReserveNames(IEnumerable<string> names) => _reservedNames.UnionWith(names);

/// <summary>
/// Gets the name of an automatic text style of <paramref name="part"/> that renders the formatting of
Expand Down Expand Up @@ -63,7 +71,7 @@ internal sealed class OdtTextStyles

if (name == null)
{
name = CreateUniqueName(part.Root);
name = CreateUniqueName(part.Root, _reservedNames);
automaticStyles.Add(new XElement(
_styleElement,
new XAttribute(_styleName, name),
Expand Down Expand Up @@ -146,11 +154,12 @@ private static XElement GetOrCreateAutomaticStyles(XElement root)
return automaticStyles;
}

private static string CreateUniqueName(XElement root)
private static string CreateUniqueName(XElement root, HashSet<string> reservedNames)
{
HashSet<string> used = new HashSet<string>(
root.Descendants().Select(e => (string?)e.Attribute(_styleName)).OfType<string>(),
StringComparer.Ordinal);
used.UnionWith(reservedNames);

for (int n = 1; ; n++)
{
Expand Down
Loading