diff --git a/README.md b/README.md index 99917d0..0ba36d1 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,35 @@ A minimal, zero-dependency .NET library for generating PDF documents from text a - **Text-to-PDF** — Create PDF documents with positioned or auto-wrapped text - **Excel-to-PDF** — Convert `.xlsx` files to paginated PDF with automatic column layout +- **Text Color** — Per-cell font color support in both text and Excel-to-PDF conversion +- **PDF Metadata** — Set document Title, Author, Subject, Keywords, and Creator - **Zero dependencies** — Uses only built-in .NET APIs (no external packages) - **Valid PDF 1.4** output with Helvetica font +- **Input validation** — All public APIs validate arguments with descriptive exceptions + +## Supported Feature Matrix + +| Feature | Status | Notes | +|---|---|---| +| Text positioning | ✅ | Absolute (x,y) placement | +| Text wrapping | ✅ | Auto-wrap within specified width | +| Text color | ✅ | RGB color via `PdfColor` class | +| Excel cell text | ✅ | Shared strings, inline strings, numbers | +| Excel text color | ✅ | ARGB hex, indexed colors from styles.xml | +| Multi-sheet support | ✅ | All sheets rendered sequentially | +| Pagination | ✅ | Automatic page breaks for long content | +| Column auto-sizing | ✅ | Based on content width | +| Sheet name headers | ✅ | Optional, configurable | +| PDF metadata | ✅ | Title, Author, Subject, Keywords, Creator | +| Page size options | ✅ | US Letter (default), A4, or custom | +| Bold / italic | ❌ | Planned | +| Cell background color | ❌ | Planned | +| Text alignment | ❌ | Planned | +| Merged cells | ❌ | Planned | +| Images | ❌ | Planned | +| Number/date formatting | ❌ | Planned | +| Column width from Excel | ❌ | Planned | +| Hyperlinks | ❌ | Planned | ## Getting Started @@ -15,6 +42,12 @@ A minimal, zero-dependency .NET library for generating PDF documents from text a - .NET 9.0 or later +### Install from NuGet + +```bash +dotnet add package MiniPdf +``` + ### Build ```bash @@ -57,6 +90,39 @@ page.AddTextWrapped(longText, x: 50, y: 700, maxWidth: 500, fontSize: 12); doc.Save("wrapped.pdf"); ``` +### Text with Color + +```csharp +using MiniPdf; + +var doc = new PdfDocument(); +var page = doc.AddPage(); + +page.AddText("Red text", 50, 700, 12, PdfColor.Red); +page.AddText("Blue text", 50, 680, 12, PdfColor.Blue); +page.AddText("Custom color", 50, 660, 12, PdfColor.FromRgb(128, 64, 0)); +page.AddText("Hex color", 50, 640, 12, PdfColor.FromHex("#FF8C00")); + +doc.Save("colored.pdf"); +``` + +### PDF Metadata + +```csharp +using MiniPdf; + +var doc = new PdfDocument(); +doc.Title = "My Document"; +doc.Author = "John Doe"; +doc.Subject = "Sample PDF"; +doc.Keywords = "pdf, sample, minipdf"; +doc.Creator = "MiniPdf"; + +doc.AddPage().AddText("Hello with metadata!", 50, 700); + +doc.Save("metadata.pdf"); +``` + ### Excel to PDF ```csharp @@ -97,9 +163,10 @@ byte[] bytes = doc.ToArray(); ``` MiniPdf.sln ├── src/MiniPdf/ # Library -│ ├── PdfDocument.cs # Document model +│ ├── PdfDocument.cs # Document model (pages + metadata) │ ├── PdfPage.cs # Page with text placement │ ├── PdfTextBlock.cs # Text block data +│ ├── PdfColor.cs # RGB color for text rendering │ ├── PdfWriter.cs # PDF 1.4 binary writer │ ├── ExcelReader.cs # .xlsx parser (ZIP + XML) │ └── ExcelToPdfConverter.cs# Excel-to-PDF public API diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3de5911 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,42 @@ +# MiniPdf Roadmap + +This document tracks planned features and improvements for the MiniPdf library. + +## v0.1.0 (Current) + +- [x] Text-to-PDF with positioned text +- [x] Auto-wrapped text within width bounds +- [x] Excel-to-PDF with automatic column layout +- [x] Multi-sheet support +- [x] Automatic pagination +- [x] Text color support (PdfColor) +- [x] Excel font color reading (ARGB hex, indexed colors) +- [x] PDF metadata (Title, Author, Subject, Keywords, Creator) +- [x] Input validation on all public APIs +- [x] NuGet packaging with README + +## v0.2.0 (Planned) + +- [ ] Bold / italic / underline text (Helvetica-Bold, Helvetica-Oblique) +- [ ] Per-cell font size from Excel +- [ ] Text alignment (left, center, right) +- [ ] Cell background / fill color +- [ ] Column width from Excel (`` widths) +- [ ] Row height from Excel + +## v0.3.0 (Planned) + +- [ ] Merged cell support +- [ ] Number and date formatting from Excel format codes +- [ ] Hyperlink annotations +- [ ] Headers and footers (page numbers, sheet name, date) +- [ ] Page setup from Excel (orientation, paper size, margins) + +## Future Considerations + +- [ ] Image support (JPEG/PNG embedding) +- [ ] Multi-target frameworks (netstandard2.0, net6.0, net8.0) +- [ ] Async conversion APIs +- [ ] Configuration class for advanced options +- [ ] Streaming reads/writes for large file optimization +- [ ] PDF security features (passwords, permissions) diff --git a/src/MiniPdf/ExcelToPdfConverter.cs b/src/MiniPdf/ExcelToPdfConverter.cs index 8072fc7..c43784d 100644 --- a/src/MiniPdf/ExcelToPdfConverter.cs +++ b/src/MiniPdf/ExcelToPdfConverter.cs @@ -52,6 +52,7 @@ public sealed class ConversionOptions /// A PdfDocument containing the Excel data. public static PdfDocument Convert(string excelPath, ConversionOptions? options = null) { + ArgumentException.ThrowIfNullOrEmpty(excelPath); using var stream = File.OpenRead(excelPath); return Convert(stream, options); } @@ -64,6 +65,7 @@ public static PdfDocument Convert(string excelPath, ConversionOptions? options = /// A PdfDocument containing the Excel data. public static PdfDocument Convert(Stream excelStream, ConversionOptions? options = null) { + ArgumentNullException.ThrowIfNull(excelStream); options ??= new ConversionOptions(); var sheets = ExcelReader.ReadSheets(excelStream); var doc = new PdfDocument(); @@ -90,6 +92,8 @@ public static PdfDocument Convert(Stream excelStream, ConversionOptions? options /// Optional conversion settings. public static void ConvertToFile(string excelPath, string pdfPath, ConversionOptions? options = null) { + ArgumentException.ThrowIfNullOrEmpty(excelPath); + ArgumentException.ThrowIfNullOrEmpty(pdfPath); var doc = Convert(excelPath, options); doc.Save(pdfPath); } diff --git a/src/MiniPdf/MiniPdf.csproj b/src/MiniPdf/MiniPdf.csproj index 2c98185..d16ec02 100644 --- a/src/MiniPdf/MiniPdf.csproj +++ b/src/MiniPdf/MiniPdf.csproj @@ -9,9 +9,18 @@ MiniPdf 0.1.0 - A minimal, zero-dependency .NET library for generating PDF documents from text and Excel files. - pdf;excel;xlsx;converter;text + shps951023 + A minimal, zero-dependency .NET library for generating PDF documents from text and Excel (.xlsx) files. Supports text-to-PDF with positioning and wrapping, Excel-to-PDF with automatic column layout and pagination, text color, and PDF metadata. + pdf;excel;xlsx;converter;text;document;generation MIT + README.md + https://github.com/shps951023/MiniPdf + git + https://github.com/shps951023/MiniPdf + + + + diff --git a/src/MiniPdf/PdfDocument.cs b/src/MiniPdf/PdfDocument.cs index d42d52d..5594c2d 100644 --- a/src/MiniPdf/PdfDocument.cs +++ b/src/MiniPdf/PdfDocument.cs @@ -12,6 +12,21 @@ public sealed class PdfDocument /// public IReadOnlyList Pages => _pages; + /// Document title. + public string? Title { get; set; } + + /// Document author. + public string? Author { get; set; } + + /// Document subject. + public string? Subject { get; set; } + + /// Document keywords. + public string? Keywords { get; set; } + + /// Creator application name. + public string? Creator { get; set; } + /// /// Adds a new page to the document. /// @@ -20,6 +35,8 @@ public sealed class PdfDocument /// The newly created page. public PdfPage AddPage(float width = 612, float height = 792) { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(width, 0); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(height, 0); var page = new PdfPage(width, height); _pages.Add(page); return page; @@ -30,6 +47,7 @@ public PdfPage AddPage(float width = 612, float height = 792) /// public void Save(string filePath) { + ArgumentException.ThrowIfNullOrEmpty(filePath); using var stream = File.Create(filePath); Save(stream); } @@ -39,6 +57,7 @@ public void Save(string filePath) /// public void Save(Stream stream) { + ArgumentNullException.ThrowIfNull(stream); var writer = new PdfWriter(stream); writer.Write(this); } diff --git a/src/MiniPdf/PdfPage.cs b/src/MiniPdf/PdfPage.cs index 38449c3..6a4bc3d 100644 --- a/src/MiniPdf/PdfPage.cs +++ b/src/MiniPdf/PdfPage.cs @@ -39,6 +39,7 @@ internal PdfPage(float width, float height) /// The current page for chaining. public PdfPage AddText(string text, float x, float y, float fontSize = 12, PdfColor? color = null) { + ArgumentNullException.ThrowIfNull(text); _textBlocks.Add(new PdfTextBlock(text, x, y, fontSize, color)); return this; } @@ -57,7 +58,9 @@ public PdfPage AddText(string text, float x, float y, float fontSize = 12, PdfCo /// The current page for chaining. public PdfPage AddTextWrapped(string text, float x, float y, float maxWidth, float fontSize = 12, float lineSpacing = 1.2f, PdfColor? color = null) { - if (string.IsNullOrEmpty(text)) + ArgumentNullException.ThrowIfNull(text); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(maxWidth, 0); + if (text.Length == 0) return this; var lineHeight = fontSize * lineSpacing; diff --git a/src/MiniPdf/PdfWriter.cs b/src/MiniPdf/PdfWriter.cs index 93072f7..2179ee0 100644 --- a/src/MiniPdf/PdfWriter.cs +++ b/src/MiniPdf/PdfWriter.cs @@ -63,6 +63,21 @@ internal void Write(PdfDocument document) _objectOffsets[3] = Position; WriteRaw("3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n"); + // Determine if we need an Info dictionary + var hasMetadata = !string.IsNullOrEmpty(document.Title) + || !string.IsNullOrEmpty(document.Author) + || !string.IsNullOrEmpty(document.Subject) + || !string.IsNullOrEmpty(document.Keywords) + || !string.IsNullOrEmpty(document.Creator); + + var infoObj = 0; + if (hasMetadata) + { + infoObj = nextObj++; + _objectCount = nextObj - 1; + _objectOffsets.Add(0); + } + // Write each page and its content stream for (var i = 0; i < document.Pages.Count; i++) { @@ -89,6 +104,24 @@ internal void Write(PdfDocument document) WriteRaw("endobj\n"); } + // Write Info dictionary if metadata is present + if (hasMetadata) + { + _objectOffsets[infoObj] = Position; + WriteRaw($"{infoObj} 0 obj\n<< "); + if (!string.IsNullOrEmpty(document.Title)) + WriteRaw($"/Title ({EscapePdfString(document.Title)}) "); + if (!string.IsNullOrEmpty(document.Author)) + WriteRaw($"/Author ({EscapePdfString(document.Author)}) "); + if (!string.IsNullOrEmpty(document.Subject)) + WriteRaw($"/Subject ({EscapePdfString(document.Subject)}) "); + if (!string.IsNullOrEmpty(document.Keywords)) + WriteRaw($"/Keywords ({EscapePdfString(document.Keywords)}) "); + if (!string.IsNullOrEmpty(document.Creator)) + WriteRaw($"/Creator ({EscapePdfString(document.Creator)}) "); + WriteRaw(">>\nendobj\n"); + } + // Write xref table var xrefOffset = Position; WriteRaw("xref\n"); @@ -101,7 +134,8 @@ internal void Write(PdfDocument document) // Write trailer WriteRaw("trailer\n"); - WriteRaw($"<< /Size {_objectCount + 1} /Root 1 0 R >>\n"); + var trailerExtra = hasMetadata ? $" /Info {infoObj} 0 R" : ""; + WriteRaw($"<< /Size {_objectCount + 1} /Root 1 0 R{trailerExtra} >>\n"); WriteRaw("startxref\n"); WriteRaw($"{xrefOffset}\n"); WriteRaw("%%EOF\n"); diff --git a/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs b/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs index f74b68e..30c04fd 100644 --- a/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs +++ b/tests/MiniPdf.Tests/ExcelToPdfConverterTests.cs @@ -405,4 +405,34 @@ int GetStringIndex(string value) ms.Position = 0; return ms; } + + [Fact] + public void Convert_NullPath_Throws() + { + Assert.Throws(() => ExcelToPdfConverter.Convert((string)null!)); + } + + [Fact] + public void Convert_EmptyPath_Throws() + { + Assert.Throws(() => ExcelToPdfConverter.Convert("")); + } + + [Fact] + public void Convert_NullStream_Throws() + { + Assert.Throws(() => ExcelToPdfConverter.Convert((Stream)null!)); + } + + [Fact] + public void ConvertToFile_NullExcelPath_Throws() + { + Assert.Throws(() => ExcelToPdfConverter.ConvertToFile(null!, "out.pdf")); + } + + [Fact] + public void ConvertToFile_NullPdfPath_Throws() + { + Assert.Throws(() => ExcelToPdfConverter.ConvertToFile("in.xlsx", null!)); + } } diff --git a/tests/MiniPdf.Tests/PdfDocumentTests.cs b/tests/MiniPdf.Tests/PdfDocumentTests.cs index d0b3f47..6e3f7a2 100644 --- a/tests/MiniPdf.Tests/PdfDocumentTests.cs +++ b/tests/MiniPdf.Tests/PdfDocumentTests.cs @@ -159,4 +159,126 @@ public void EmptyDocument_ProducesValidPdf() Assert.Contains("%%EOF", content); Assert.Contains("/Type /Page", content); } + + [Fact] + public void AddPage_ZeroWidth_Throws() + { + var doc = new PdfDocument(); + Assert.Throws(() => doc.AddPage(width: 0, height: 100)); + } + + [Fact] + public void AddPage_NegativeHeight_Throws() + { + var doc = new PdfDocument(); + Assert.Throws(() => doc.AddPage(width: 100, height: -1)); + } + + [Fact] + public void Save_NullFilePath_Throws() + { + var doc = new PdfDocument(); + doc.AddPage(); + Assert.Throws(() => doc.Save((string)null!)); + } + + [Fact] + public void Save_EmptyFilePath_Throws() + { + var doc = new PdfDocument(); + doc.AddPage(); + Assert.Throws(() => doc.Save("")); + } + + [Fact] + public void Save_NullStream_Throws() + { + var doc = new PdfDocument(); + doc.AddPage(); + Assert.Throws(() => doc.Save((Stream)null!)); + } + + [Fact] + public void AddText_NullText_Throws() + { + var doc = new PdfDocument(); + var page = doc.AddPage(); + Assert.Throws(() => page.AddText(null!, 0, 0)); + } + + [Fact] + public void AddTextWrapped_NullText_Throws() + { + var doc = new PdfDocument(); + var page = doc.AddPage(); + Assert.Throws(() => page.AddTextWrapped(null!, 0, 0, 100)); + } + + [Fact] + public void AddTextWrapped_ZeroMaxWidth_Throws() + { + var doc = new PdfDocument(); + var page = doc.AddPage(); + Assert.Throws(() => page.AddTextWrapped("text", 0, 0, 0)); + } + + [Fact] + public void Metadata_Title_IncludedInPdf() + { + var doc = new PdfDocument(); + doc.Title = "Test Title"; + doc.AddPage(); + + var bytes = doc.ToArray(); + var content = System.Text.Encoding.ASCII.GetString(bytes); + + Assert.Contains("/Title (Test Title)", content); + } + + [Fact] + public void Metadata_AllProperties_IncludedInPdf() + { + var doc = new PdfDocument(); + doc.Title = "My Title"; + doc.Author = "My Author"; + doc.Subject = "My Subject"; + doc.Keywords = "My Keywords"; + doc.Creator = "My Creator"; + doc.AddPage(); + + var bytes = doc.ToArray(); + var content = System.Text.Encoding.ASCII.GetString(bytes); + + Assert.Contains("/Title (My Title)", content); + Assert.Contains("/Author (My Author)", content); + Assert.Contains("/Subject (My Subject)", content); + Assert.Contains("/Keywords (My Keywords)", content); + Assert.Contains("/Creator (My Creator)", content); + Assert.Contains("/Info", content); + } + + [Fact] + public void Metadata_None_NoInfoDictionary() + { + var doc = new PdfDocument(); + doc.AddPage(); + + var bytes = doc.ToArray(); + var content = System.Text.Encoding.ASCII.GetString(bytes); + + Assert.DoesNotContain("/Info", content); + } + + [Fact] + public void Metadata_SpecialChars_Escaped() + { + var doc = new PdfDocument(); + doc.Title = "Hello (World)"; + doc.AddPage(); + + var bytes = doc.ToArray(); + var content = System.Text.Encoding.ASCII.GetString(bytes); + + Assert.Contains("/Title (Hello \\(World\\))", content); + } }