diff --git a/.github/workflows/dependabot-commit-lowercase.yml b/.github/workflows/dependabot-commit-lowercase.yml index f637f48..e19eded 100644 --- a/.github/workflows/dependabot-commit-lowercase.yml +++ b/.github/workflows/dependabot-commit-lowercase.yml @@ -1,16 +1,19 @@ name: Fix Dependabot Commit Casing on: - pull_request: + pull_request_target: types: [opened, edited] +permissions: + pull-requests: write + jobs: fix-commit-description: if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: - name: Make commit description commitlint compliant - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const title = context.payload.pull_request.title; diff --git a/Panels.Tests/JsonParserTests.cs b/Panels.Tests/JsonParserTests.cs new file mode 100644 index 0000000..d9fcfaa --- /dev/null +++ b/Panels.Tests/JsonParserTests.cs @@ -0,0 +1,171 @@ +using System.Reflection; +using Newtonsoft.Json; +using Panels; +using Panels.Configuration; +using Panels.Elements; + +namespace Panels.Tests; + +public class JsonParserTests +{ + private static string ProjectRoot => + Directory.GetParent(Environment.CurrentDirectory)?.Parent?.Parent?.FullName + ?? throw new InvalidOperationException("Project root not found"); + + [Fact] + public void Read_loads_comic_from_file() + { + var jsonPath = Path.Combine(ProjectRoot, "Snapshot/testjson01/bd.json"); + + var comic = JsonParser.Read(jsonPath); + + Assert.Equal(10, comic.FontSize); + Assert.Equal(2, comic.children.Count); + Assert.IsType(comic.children[0]); + } + + [Fact] + public void ReadFromString_deserializes_typed_children() + { + const string json = """ + { + "fontSize": 12, + "children": [ + { + "$type": "slot", + "panels": [ + { + "image": "0000.png", + "elements": [ + { "$type": "text", "text": "Hello" }, + { "$type": "description", "text": "Alt text" } + ] + } + ] + }, + { "$type": "newpage" } + ] + } + """; + + var comic = JsonParser.ReadFromString(json); + + Assert.Equal(12, comic.FontSize); + Assert.Equal(2, comic.children.Count); + Assert.IsType(comic.children[0]); + Assert.IsType(comic.children[1]); + + var slot = (Slot)comic.children[0]; + Assert.Single(slot.panels); + Assert.Equal(2, slot.panels[0].elements.Count); + Assert.IsType(slot.panels[0].elements[0]); + Assert.IsType(slot.panels[0].elements[1]); + } + + [Fact] + public void Write_round_trips_comic_through_file() + { + var sourcePath = Path.Combine(ProjectRoot, "Snapshot/testjson01/bd.json"); + var outputPath = Path.Combine(Path.GetTempPath(), $"panels-json-{Guid.NewGuid():N}.json"); + + try + { + var comic = JsonParser.Read(sourcePath); + JsonParser.Write(comic, outputPath); + + var roundTrip = JsonParser.Read(outputPath); + + Assert.Equal(comic.FontSize, roundTrip.FontSize); + Assert.Equal(comic.MarginTop, roundTrip.MarginTop); + Assert.Equal(comic.RowsPerPage, roundTrip.RowsPerPage); + Assert.Equal(comic.children.Count, roundTrip.children.Count); + } + finally + { + File.Delete(outputPath); + } + } + + [Fact] + public void ReadFromString_throws_when_json_is_null() + { + var exception = Assert.Throws(() => JsonParser.ReadFromString("null")); + Assert.Equal("Failed to deserialize JSON", exception.Message); + } + + [Fact] + public void ReadFromString_throws_when_type_is_unknown() + { + const string json = """ + { + "$type": "unknown", + "children": [] + } + """; + + var exception = Assert.Throws(() => JsonParser.ReadFromString(json)); + Assert.IsType(exception.InnerException); + Assert.Contains("Type not found: unknown", exception.InnerException!.Message); + } + + [Fact] + public void CustomSerializationBinder_uses_short_names_for_known_types() + { + var binder = CreateSerializationBinder(); + InvokeBindToName(binder, typeof(Slot), out var assemblyName, out var typeName); + + Assert.Null(assemblyName); + Assert.Equal("slot", typeName); + } + + [Fact] + public void CustomSerializationBinder_falls_back_to_full_name_for_unknown_types() + { + var binder = CreateSerializationBinder(); + InvokeBindToName(binder, typeof(string), out var assemblyName, out var typeName); + + Assert.Null(assemblyName); + Assert.Equal(typeof(string).FullName, typeName); + } + + [Fact] + public void CustomSerializationBinder_resolves_known_type_names() + { + var binder = CreateSerializationBinder(); + var bindToType = binder.GetType().GetMethod("BindToType") + ?? throw new InvalidOperationException("BindToType not found"); + + var type = bindToType.Invoke(binder, new object?[] { null, "panel" }); + + Assert.Equal(typeof(Panel), type); + } + + [Fact] + public void CustomSerializationBinder_resolves_assembly_qualified_type_names() + { + var binder = CreateSerializationBinder(); + var bindToType = binder.GetType().GetMethod("BindToType") + ?? throw new InvalidOperationException("BindToType not found"); + + var type = bindToType.Invoke(binder, new object?[] { typeof(string).Assembly.FullName, typeof(string).FullName }); + + Assert.Equal(typeof(string), type); + } + + private static object CreateSerializationBinder() + { + var binderType = typeof(JsonParser).GetNestedType("CustomSerializationBinder", BindingFlags.NonPublic) + ?? throw new InvalidOperationException("CustomSerializationBinder not found"); + return Activator.CreateInstance(binderType)!; + } + + private static void InvokeBindToName(object binder, Type serializedType, out string? assemblyName, out string? typeName) + { + var method = binder.GetType().GetMethod("BindToName") + ?? throw new InvalidOperationException("BindToName not found"); + var parameters = new object?[] { serializedType, null, null }; + method.Invoke(binder, parameters); + assemblyName = (string?)parameters[1]; + typeName = (string?)parameters[2]; + } +} diff --git a/Panels.Tests/Panels.Tests.csproj b/Panels.Tests/Panels.Tests.csproj index e5d0b45..dd8784b 100644 --- a/Panels.Tests/Panels.Tests.csproj +++ b/Panels.Tests/Panels.Tests.csproj @@ -10,7 +10,7 @@ cobertura Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute **/Properties/** - 75 + 80 line total diff --git a/Panels.Tests/Snapshot/Snapshot.cs b/Panels.Tests/Snapshot/Snapshot.cs index 4326156..fa5650f 100644 --- a/Panels.Tests/Snapshot/Snapshot.cs +++ b/Panels.Tests/Snapshot/Snapshot.cs @@ -38,6 +38,18 @@ public Task Verify_testxml01(string configPath, string imagesPath) return Verify(result, Settings); } + [Theory] + [InlineData("Snapshot/testxml02/bd.xml", "Snapshot/testxml01/images")] + public Task Verify_testxml02_skipFirstRow(string configPath, string imagesPath) + { + var parentDirectory = Directory.GetParent(Environment.CurrentDirectory)?.Parent?.Parent?.FullName + ?? throw new Exception("Parent directory not found"); + var fullXmlPath = Path.Combine(parentDirectory, configPath); + var fullImagesPath = Path.Combine(parentDirectory, imagesPath); + var result = Program.GeneratePdf(fullXmlPath, fullImagesPath, @"./output.pdf"); + return Verify(result, Settings); + } + [Theory] [InlineData("Snapshot/testyaml01/bd.yaml", "Snapshot/testjson01/images")] public Task Verify_testyaml01(string configPath, string imagesPath) diff --git a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testjson01.verified.txt b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testjson01.verified.txt index dd578df..31e2f14 100644 --- a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testjson01.verified.txt +++ b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testjson01.verified.txt @@ -1,6 +1,6 @@ Starting PDF generation... -IMAGE - 161.91457x231.33333 at 25, 546.6667 -TEXT - Hello ! at 30, 320.33337 with width 151.91457 -IMAGE - 160.86644x231.33333 at 196.91457, 546.6667 -TEXT - Bonjour ! at 201.91457, 320.33337 with width 150.86644 +IMAGE - 161.91457x231.33333 at 25, 788 +TEXT - Hello ! at 30, 561.6667 with width 151.91457 +IMAGE - 160.86644x231.33333 at 196.91457, 788 +TEXT - Bonjour ! at 201.91457, 561.6667 with width 150.86644 Generated ./output.pdf diff --git a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml01.verified.txt b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml01.verified.txt index 41ec9de..fb30602 100644 --- a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml01.verified.txt +++ b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml01.verified.txt @@ -1,4 +1,4 @@ Starting PDF generation... -IMAGE - 160.9627x231.33333 at 25, 546.6667 -TEXT - Hi, I'm the first panel of this comic. at 30, 320.33337 with width 150.9627 +IMAGE - 160.9627x231.33333 at 25, 788 +TEXT - Hi, I'm the first panel of this comic. at 30, 561.6667 with width 150.9627 Generated ./output.pdf diff --git a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml02_skipFirstRow.verified.txt b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml02_skipFirstRow.verified.txt new file mode 100644 index 0000000..6dd20a7 --- /dev/null +++ b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testxml02_skipFirstRow.verified.txt @@ -0,0 +1,4 @@ +Starting PDF generation... +IMAGE - 160.9627x231.33333 at 25, 546.6667 +TEXT - Hi, I'm the first panel of this comic. at 30, 320.33337 with width 150.9627 +Generated ./output.pdf diff --git a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testyaml01.verified.txt b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testyaml01.verified.txt index dd578df..31e2f14 100644 --- a/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testyaml01.verified.txt +++ b/Panels.Tests/Snapshot/snapshots/Snapshot.Verify_testyaml01.verified.txt @@ -1,6 +1,6 @@ Starting PDF generation... -IMAGE - 161.91457x231.33333 at 25, 546.6667 -TEXT - Hello ! at 30, 320.33337 with width 151.91457 -IMAGE - 160.86644x231.33333 at 196.91457, 546.6667 -TEXT - Bonjour ! at 201.91457, 320.33337 with width 150.86644 +IMAGE - 161.91457x231.33333 at 25, 788 +TEXT - Hello ! at 30, 561.6667 with width 151.91457 +IMAGE - 160.86644x231.33333 at 196.91457, 788 +TEXT - Bonjour ! at 201.91457, 561.6667 with width 150.86644 Generated ./output.pdf diff --git a/Panels.Tests/Snapshot/testxml02/bd.xml b/Panels.Tests/Snapshot/testxml02/bd.xml new file mode 100644 index 0000000..6709b30 --- /dev/null +++ b/Panels.Tests/Snapshot/testxml02/bd.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/Panels.Tests/XmlParserTests.cs b/Panels.Tests/XmlParserTests.cs index aff7514..caa12c4 100644 --- a/Panels.Tests/XmlParserTests.cs +++ b/Panels.Tests/XmlParserTests.cs @@ -1,9 +1,16 @@ -using Panels.Configuration; +using System.Reflection; +using System.Xml.Schema; +using Panels; +using Panels.Configuration; namespace Panels.Tests; public class XmlParserTests { + private static string ProjectRoot => + Directory.GetParent(Environment.CurrentDirectory)?.Parent?.Parent?.FullName + ?? throw new InvalidOperationException("Project root not found"); + [Fact] public void Read_allows_whitespace_inside_empty_elements() { @@ -31,4 +38,100 @@ public void Read_allows_whitespace_inside_empty_elements() File.Delete(xmlPath); } } -} \ No newline at end of file + + [Fact] + public void Read_deserializes_comic_attributes() + { + var xmlPath = Path.Combine(Path.GetTempPath(), $"panels-test-{Guid.NewGuid():N}.xml"); + File.WriteAllText(xmlPath, """ + + + + + + + + + """); + + try + { + var comic = XmlParser.Read(xmlPath); + + Assert.Equal(14, comic.FontSize); + Assert.Equal(4, comic.RowsPerPage); + Assert.False(comic.ShowPageNumbers); + Assert.True(comic.SkipFirstRow); + Assert.Equal(32, comic.MarginTop); + Assert.Equal(8, comic.VerticalPanelSpacing); + Assert.Single(comic.children); + + var slot = Assert.IsType(comic.children[0]); + Assert.Equal("10%", slot.MaxLeftPadding); + Assert.Equal("15%", slot.MaxRightPadding); + } + finally + { + File.Delete(xmlPath); + } + } + + [Fact] + public void Read_loads_snapshot_config() + { + var xmlPath = Path.Combine(ProjectRoot, "Snapshot/testxml01/bd.xml"); + + var comic = XmlParser.Read(xmlPath); + + Assert.Equal(3, comic.RowsPerPage); + Assert.Single(comic.children); + } + + [Fact] + public void ComicsValidationEventHandler_logs_warnings_and_errors() + { + var handler = typeof(XmlParser).GetMethod( + "ComicsValidationEventHandler", + BindingFlags.Static | BindingFlags.NonPublic + ) ?? throw new InvalidOperationException("Validation handler not found"); + + var warning = CreateValidationEventArgs(new XmlSchemaException("schema warning"), XmlSeverityType.Warning); + var error = CreateValidationEventArgs(new XmlSchemaException("schema error"), XmlSeverityType.Error); + + using var console = new StringWriter(); + var originalOut = Console.Out; + try + { + Console.SetOut(console); + handler.Invoke(null, new object?[] { null, warning }); + handler.Invoke(null, new object?[] { null, error }); + } + finally + { + Console.SetOut(originalOut); + } + + var output = console.ToString(); + Assert.Contains("WARNING: schema warning", output); + Assert.Contains("ERROR: schema error", output); + } + + private static ValidationEventArgs CreateValidationEventArgs(XmlSchemaException exception, XmlSeverityType severity) + { + return (ValidationEventArgs)Activator.CreateInstance( + typeof(ValidationEventArgs), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + args: new object[] { exception, severity }, + culture: null + )!; + } +} diff --git a/Panels/Assets/schema.xsd b/Panels/Assets/schema.xsd index e184b9a..0b9b39f 100644 --- a/Panels/Assets/schema.xsd +++ b/Panels/Assets/schema.xsd @@ -67,6 +67,7 @@ + diff --git a/Panels/Comic.cs b/Panels/Comic.cs index 2a7ed61..f1ac598 100644 --- a/Panels/Comic.cs +++ b/Panels/Comic.cs @@ -61,9 +61,13 @@ public class Comic : IRenderable [JsonProperty("showPageNumbers")] public bool ShowPageNumbers { get; set; } = true; + [XmlAttribute("skipFirstRow")] + [JsonProperty("skipFirstRow")] + public bool SkipFirstRow { get; set; } = false; + [XmlAttribute("horizontalPanelSpacing")] [JsonProperty("horizontalPanelSpacing")] - public float horizontalPanelSpacing = 0; + public float HorizontalPanelSpacing { get; set; } = 0; [XmlAttribute("verticalPanelSpacing")] [JsonProperty("verticalPanelSpacing")] @@ -108,7 +112,7 @@ public void Render(LogWriter logWriter) PageSize pageSize = this.document?.GetPdfDocument().GetDefaultPageSize() ?? PageSize.A4; var panelHeight = (pageSize.GetHeight() - this.MarginTop - this.MarginBottom - (this.RowsPerPage - 1) * this.VerticalPanelSpacing) / this.RowsPerPage; var rowWidth = pageSize.GetWidth() - this.MarginRight - this.MarginLeft; - this.CurrentY = panelHeight + this.VerticalPanelSpacing; + this.CurrentY = this.SkipFirstRow ? panelHeight + this.VerticalPanelSpacing : 0; for (var i = 0; i < this.children.Count;) { // Handle newpage elements @@ -131,10 +135,10 @@ public void Render(LogWriter logWriter) slot.SetHeight(panelHeight); var minPanelWidth = slot.GetMinWidth(); var maxPanelWidth = slot.GetMaxWidth(); - if (minWidth + minPanelWidth + (nbPanelsInRow >= 1 ? this.horizontalPanelSpacing : 0) > rowWidth) + if (minWidth + minPanelWidth + (nbPanelsInRow >= 1 ? this.HorizontalPanelSpacing : 0) > rowWidth) break; - minWidth += minPanelWidth + (nbPanelsInRow >= 1 ? this.horizontalPanelSpacing : 0); - maxWidth += maxPanelWidth + (nbPanelsInRow >= 1 ? this.horizontalPanelSpacing : 0); + minWidth += minPanelWidth + (nbPanelsInRow >= 1 ? this.HorizontalPanelSpacing : 0); + maxWidth += maxPanelWidth + (nbPanelsInRow >= 1 ? this.HorizontalPanelSpacing : 0); } // ============================= @@ -198,7 +202,7 @@ public void Render(LogWriter logWriter) ); slot.Render(logWriter); - this.CurrentX += slot.Width + this.horizontalPanelSpacing; + this.CurrentX += slot.Width + this.HorizontalPanelSpacing; } this.CurrentX = 0; i += nbPanelsInRow; diff --git a/README.md b/README.md index 33aba04..95ced1e 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ The root element. Attributes: - `marginRight` (optional): Margins in pixels. - `rowsPerPage` (optional, default: 3): Number of rows per page. - `showPageNumbers` (optional, default: true): When true, shows page numbers at the bottom center of each page. Set to false to hide them. +- `skipFirstRow` (optional, default: false): When true, leaves the first row empty on the first page so content starts on the second row. - `verticalPanelSpacing` (optional): Vertical spacing between panels in pixels. A `` can contain multiple `` and `` elements. @@ -211,6 +212,8 @@ Forces a page break. Can be placed between slots. ### Migrating from 1.x to 2.x - `maxPaddingLeft`, `maxPaddingRight`, `paddingBottom` and `paddingTop` now require to explicitly set the units as `%`. Simply add a `%` at the end of the value for these attributes. +- The default font is now `Comic Neue Bold`. To use the previous default font, set the `font` attribute to `Comicsam-Bold` in the `` element. +- The first row of the first page is not skipped by default anymore. To keep the previous behavior, set the `skipFirstRow` attribute to `true` in the `` element. ## Development