diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ecc7f..15fc26a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Positioned PNG/JPEG images on `VectorCanvas` from file paths, byte arrays, + and streams, with `Stretch`, `Contain`, centred or top-left `Cover`, and + natural-size top-left crop modes. Image clipping is isolated from subsequent + canvas commands. +- Optional clockwise text rotation around the canvas text baseline point for + standard and registered custom fonts. +- Elliptical arcs and closed sectors through `PathDescriptor`, plus + `FillPie`, `StrokePie`, and `DrawPie` canvas conveniences. +- Native dash patterns and phases for canvas lines and stroked rectangles. + +### Fixed +- PNG decoding now supports 8-bit grayscale images with alpha (colour type 4). + +### Changed +- Updated `Microsoft.SourceLink.GitHub` to `10.0.401` to remove the vulnerable + transitive `Microsoft.Build.Tasks.Git` 8.0.0 dependency. + --- ## [2.1.0] - 2026-09-03 diff --git a/README.md b/README.md index 0c628bf..fb0e249 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Devanagari-aware rendering (conjunct ligatures, reph, and below-base 'ra' forms, shaping engine) and automatic glyph subsetting, so only the glyphs a document actually uses are embedded. See [Custom Fonts](https://github.com/sahebansari/TerraPDF/blob/master/docs/custom-fonts.md). > -> Also: Code128 barcodes and QR codes (ISO/IEC 18004, versions 1-40, all four error correction levels) via `container.Barcode(...)` and `container.QrCode(...)` — rendered as vector-filled rectangles, no raster image pipeline, placeable anywhere a `Column`, `Row`, or `Table` cell can go. The vector canvas (`container.Canvas(...)`) can now place text labels and draw with constant-alpha transparency. TerraPDF also multi-targets **.NET 10 (LTS)** alongside .NET 8 and 9. +> Also: Code128 barcodes and QR codes (ISO/IEC 18004, versions 1-40, all four error correction levels) via `container.Barcode(...)` and `container.QrCode(...)` — rendered as vector-filled rectangles, no raster image pipeline, placeable anywhere a `Column`, `Row`, or `Table` cell can go. The vector canvas (`container.Canvas(...)`) supports positioned and clipped images, rotated text, arcs and pie sectors, dashed strokes, and constant-alpha transparency. TerraPDF also multi-targets **.NET 10 (LTS)** alongside .NET 8 and 9. TerraPDF is a lightweight, zero-dependency, pure C# PDF 1.7 writer for programmatic document generation. Its fluent API covers the full document-authoring lifecycle: page layout, rich text, tables, images, @@ -82,7 +82,7 @@ runtime packages. - **Images from bytes and streams** — `Image(byte[])` / `Image(Stream)` with transparency and deduplication - **Anchor-based bookmarks** — bookmark content directly to rendered elements and keep destinations accurate - Full **WinAnsiEncoding** character coverage -- **Vector graphics canvas** - lines, rectangles, rounded rectangles, circles, ellipses, arbitrary Bezier paths, polygons, text labels, and grid helpers via `container.Canvas()` +- **Vector graphics canvas** - lines, dashed strokes, rectangles, circles, ellipses, arbitrary Bezier paths, arcs, pie sectors, rotated text, positioned images with fit/crop modes, and grid helpers via `container.Canvas()` - **Constant-alpha transparency** - translucent fills, strokes, and text on the vector canvas via `/ExtGState`, with `opacity` on every canvas primitive - **Code128 barcodes** - `container.Barcode(...)`, with optional human-readable caption, custom colours, and quiet zone - **QR codes** - `container.QrCode(...)`, full ISO/IEC 18004 generator (versions 1-40, error correction levels L/M/Q/H), rendered as vector rectangles @@ -156,12 +156,12 @@ For complete API reference and detailed guides, visit the [docs](https://github. - **[Layout](https://github.com/sahebansari/TerraPDF/blob/master/docs/layout.md)** — Column, Row, and Table layouts with alignment, spacing, and cell spans - **[Row and Column Layout](https://github.com/sahebansari/TerraPDF/blob/master/docs/row-and-column-layout.md)** — Detailed Row and Column layout examples - **[Decorators](https://github.com/sahebansari/TerraPDF/blob/master/docs/decorators.md)** — Padding, margin, backgrounds, borders, and styling -- **[Images](https://github.com/sahebansari/TerraPDF/blob/master/docs/images.md)** — PNG and JPEG embedding with sizing and alignment +- **[Images](https://github.com/sahebansari/TerraPDF/blob/master/docs/images.md)** — PNG and JPEG embedding, positioned canvas images, fit modes, clipping, sizing, and alignment - **[Page Sizes & Units](https://github.com/sahebansari/TerraPDF/blob/master/docs/page-sizes-and-units.md)** — Built-in page sizes and unit conversions - **[Colors](https://github.com/sahebansari/TerraPDF/blob/master/docs/colors.md)** — Material Design color palette with shades - **[Encryption & Security](https://github.com/sahebansari/TerraPDF/blob/master/docs/encryption.md)** — AES-256 by default, with AES-128 compatibility mode and permission flags - **[Custom Fonts](https://github.com/sahebansari/TerraPDF/blob/master/docs/custom-fonts.md)** — embed TrueType fonts for brand typefaces and full Unicode (Cyrillic, Greek, and beyond) -- **[Vector Graphics](https://github.com/sahebansari/TerraPDF/blob/master/docs/vector-graphics.md)** — Canvas API, shapes, paths, text labels, transparency, grids, and charts +- **[Vector Graphics](https://github.com/sahebansari/TerraPDF/blob/master/docs/vector-graphics.md)** — Canvas API, images, rotated text, dashed strokes, arcs, sectors, transparency, grids, and charts - **[Table of Contents](https://github.com/sahebansari/TerraPDF/blob/master/docs/table-of-contents.md)** — Automatic TOC generation from headings - **[Bookmarks](https://github.com/sahebansari/TerraPDF/blob/master/docs/bookmarks.md)** — PDF bookmarks and outlines - **[Components & Templates](https://github.com/sahebansari/TerraPDF/blob/master/docs/components-and-templates.md)** — Reusable components and document templates diff --git a/docs/images.md b/docs/images.md index 3c12035..fa0569b 100644 --- a/docs/images.md +++ b/docs/images.md @@ -78,6 +78,53 @@ container.Image("icon.png", 32); --- +## Positioned Images on a Vector Canvas + +`VectorCanvas.Image(...)` places an image in an absolute target rectangle. All +coordinates and dimensions are PDF points relative to the canvas's top-left +origin. File paths, raw bytes, and streams are supported: + +```csharp +container.Canvas(220, canvas => +{ + canvas.Image("photo.jpg", 0, 0, 180, 120, ImageFit.Contain); + canvas.Image(logoBytes, 200, 0, 180, 120, ImageFit.Cover); + + using Stream source = OpenImage(); + canvas.Image(source, 400, 0, 80, 80, ImageFit.Stretch); +}); +``` + +The format is detected from PNG or JPEG magic bytes, not the file extension. +Byte arrays and remaining stream data are copied when `Image` is called because +the canvas is rendered later. A supplied stream is read from its current +position and remains owned by the caller; TerraPDF never disposes it. + +Use `VectorCanvas.GetImageSizeInPoints(imageData)` when a target rectangle +should match the natural image size. Pixel dimensions are converted at 96 DPI: + +```csharp +var (width, height) = VectorCanvas.GetImageSizeInPoints(logoBytes); +canvas.Image(logoBytes, 20, 20, width, height); +``` + +### Canvas image fit modes + +| Mode | Aspect ratio | Position | Clipping | +|------|--------------|----------|----------| +| `Stretch` | May distort | Fills the target | No | +| `Contain` | Preserved | Centred inside the target | No | +| `Cover` | Preserved | Centred over the target | Yes | +| `CoverTopLeft` | Preserved | Anchored at the target's top-left | Yes | +| `CropTopLeft` | Natural 96-DPI size | Anchored at the target's top-left | Yes | + +`Cover`, `CoverTopLeft`, and `CropTopLeft` isolate clipping with PDF graphics +state save/restore operators. A shape or image drawn afterward is therefore not +affected. The clipping rectangle may extend beyond the canvas; it is not +automatically intersected with the canvas bounds. + +--- + ## Combining with Other Decorators Images participate in the full decorator chain: diff --git a/docs/vector-graphics.md b/docs/vector-graphics.md index 727556e..90735d5 100644 --- a/docs/vector-graphics.md +++ b/docs/vector-graphics.md @@ -2,8 +2,8 @@ TerraPDF provides a fluent **Canvas API** for drawing vector graphics directly inside any layout container. You can render lines, rectangles, circles, ellipses, rounded -rectangles, arbitrary Bézier paths, polygons, text labels, and grids — all without -any external dependencies. +rectangles, arbitrary Bézier paths, polygons, arcs, pie sectors, positioned images, +rotated text labels, and grids — all without any external dependencies. --- @@ -52,7 +52,8 @@ Every method returns `this` so calls can be chained. ### Lines ```csharp -canvas.Line(x1, y1, x2, y2, hexColor = "#000000", lineWidth = 1, opacity = 1); +canvas.Line(x1, y1, x2, y2, hexColor = "#000000", lineWidth = 1, opacity = 1, + dashPattern = null, dashPhase = 0); ``` Draws a straight line from `(x1, y1)` to `(x2, y2)`. @@ -60,8 +61,14 @@ Draws a straight line from `(x1, y1)` to `(x2, y2)`. ```csharp c.Line(0, 20, 300, 20, "#CCCCCC", 0.5); // thin grey rule c.Line(0, 0, 150, 80, Color.Red.Medium, 2); +c.Line(0, 60, 300, 60, dashPattern: [8, 4], dashPhase: 2); ``` +Dash arrays alternate painted and skipped lengths in points. They must contain +at least one positive finite value and cannot contain negative values. The +pattern is copied when the command is added. Each dashed command restores the +PDF graphics state, so a following line remains solid. + --- ### Rectangles @@ -80,6 +87,9 @@ canvas.DrawRect(x, y, width, height, fillHex = "#FFFFFF", strokeHex = "#000000", lineWidth = 1, opacity = 1); ``` +`StrokeRect` and `DrawRect` also accept trailing `dashPattern` and `dashPhase` +arguments with the same semantics as `Line`. + ```csharp c.FillRect ( 0, 0, 80, 50, Color.Blue.Lighten3); c.StrokeRect(100, 0, 80, 50, Color.Blue.Darken2, 1.5); @@ -150,7 +160,7 @@ c.FillEllipse(100, 40, 80, 30, Color.Purple.Lighten3); // wide, flat ellipse ```csharp canvas.Text(text, x, y, hexColor = "#000000", fontSize = 12, - fontFamily = null, bold = false, italic = false, opacity = 1); + fontFamily = null, bold = false, italic = false, opacity = 1, angle = 0); ``` Places one line of text with its **baseline** at `(x, y)` — not the top-left @@ -162,9 +172,14 @@ matches one, otherwise through the standard-14 family it resolves to exactly the same resolution every other TerraPDF text API uses. `Text` draws one line only: no wrapping, no automatic fitting. +`angle` rotates clockwise in degrees around the baseline point `(x, y)` in the +top-left canvas coordinate system. Negative angles rotate counter-clockwise; +values outside one revolution are accepted unchanged. + ```csharp c.Text("Q1", 10, 100, Color.Grey.Darken2, 9); // axis label c.Text("Revenue", 10, 20, Color.Blue.Darken2, 16, bold: true); // title +c.Text("Vertical", 220, 80, angle: 90); // rotated around its baseline c.Text("वित्तीय रिपोर्ट", 10, 140, fontFamily: "NotoSansDevanagari"); // via a registered custom font ``` @@ -180,6 +195,46 @@ c.Text(label, (canvasWidth - w) / 2, 20, bold: true); // centred --- +### Positioned images + +```csharp +canvas.Image(path, x, y, width, height, ImageFit.Contain); +canvas.Image(bytes, x, y, width, height, ImageFit.Cover); +canvas.Image(stream, x, y, width, height, ImageFit.Stretch); +``` + +Coordinates and dimensions are points. `Contain` centres the complete image; +`Cover` centres and clips it; `CoverTopLeft` clips from the top-left; +`CropTopLeft` keeps the natural 96-DPI size and clips right/bottom overflow. +Internally, TerraPDF computes the image transformation matrix and scopes any +clip with `q`/`Q`, so later canvas commands are unaffected. See +[Images](images.md#positioned-images-on-a-vector-canvas) for source ownership +and the complete fit-mode table. + +--- + +### Arcs and pie sectors + +`PathDescriptor.Arc` appends an elliptical arc, while `Sector` connects the arc +to its centre and closes the path. Convenience methods render sectors directly: + +```csharp +canvas.FillPie(x, y, width, height, startAngle, sweepAngle, fillHex); +canvas.StrokePie(x, y, width, height, startAngle, sweepAngle, strokeHex, lineWidth); +canvas.DrawPie(x, y, width, height, startAngle, sweepAngle, fillHex, strokeHex, lineWidth); + +canvas.Path(path => path + .Arc(100, 60, 80, 40, startAngle: 15, sweepAngle: 220) + .Stroke(Color.Blue.Darken2, 2)); +``` + +Angles start at the ellipse's right-hand point and increase clockwise. Negative +sweeps run counter-clockwise. A zero sweep adds no path; sweeps whose absolute +value exceeds 360 degrees retain every revolution. Arcs are split into cubic +Bézier segments of at most 90 degrees. + +--- + ### Grid helper Draws a full-canvas grid of evenly spaced vertical and horizontal lines: @@ -243,6 +298,8 @@ These helpers append subpaths to the current descriptor: | `Rect(x, y, width, height)` | Rectangular subpath | | `Ellipse(cx, cy, rx, ry)` | Ellipse subpath (cubic Bézier approximation) | | `Circle(cx, cy, radius)` | Circle subpath | +| `Arc(cx,cy,rx,ry,start,sweep)` | Append an elliptical arc | +| `Sector(cx,cy,rx,ry,start,sweep)` | Append a closed elliptical sector | | `Polyline((x,y)[] points)` | Open polyline through 2+ points | | `Polygon((x,y)[] points)` | Closed polygon through 3+ points | @@ -315,10 +372,10 @@ content. | Method | Description | |--------|-------------| -| `Line(x1,y1, x2,y2, color, lw, opacity)` | Straight line | +| `Line(x1,y1, x2,y2, color, lw, opacity, dash?, phase)` | Solid or dashed straight line | | `FillRect(x,y,w,h, color, opacity)` | Filled rectangle | -| `StrokeRect(x,y,w,h, color, lw, opacity)` | Stroked rectangle | -| `DrawRect(x,y,w,h, fill, stroke, lw, opacity)` | Filled + stroked rectangle | +| `StrokeRect(x,y,w,h, color, lw, opacity, dash?, phase)` | Solid or dashed stroked rectangle | +| `DrawRect(x,y,w,h, fill, stroke, lw, opacity, dash?, phase)` | Filled + solid or dashed rectangle | | `FillRoundedRect(x,y,w,h, r, color, opacity)` | Filled rounded rectangle | | `StrokeRoundedRect(x,y,w,h, r, color, lw, opacity)` | Stroked rounded rectangle | | `DrawRoundedRect(x,y,w,h, r, fill, stroke, lw, opacity)` | Filled + stroked rounded rect | @@ -329,7 +386,11 @@ content. | `StrokeEllipse(cx,cy, rx,ry, color, lw, opacity)` | Stroked ellipse | | `DrawEllipse(cx,cy, rx,ry, fill, stroke, lw, opacity)` | Filled + stroked ellipse | | `Path(Action)` | Arbitrary path with full Bézier support | -| `Text(text, x,y, color, size, family, bold, italic, opacity)` | Text label, baseline at (x, y) | +| `Image(source, x,y,w,h, fit)` | Positioned PNG/JPEG from a file, bytes, or stream | +| `FillPie(x,y,w,h,start,sweep,fill,opacity)` | Filled elliptical sector | +| `StrokePie(x,y,w,h,start,sweep,stroke,lw,opacity)` | Stroked elliptical sector | +| `DrawPie(x,y,w,h,start,sweep,fill,stroke,lw,opacity)` | Filled and stroked elliptical sector | +| `Text(text, x,y, color, size, family, bold, italic, opacity, angle)` | Rotatable text label, baseline at (x, y) | | `MeasureTextWidth(text, size, family, bold, italic)` (static) | Advance width for aligning/centring a label | | `Grid(cw, ch?, color, lw)` | Full-canvas rectangular grid | @@ -346,6 +407,8 @@ content. | `Rect(x,y,w,h)` | Append rectangular subpath | | `Ellipse(cx,cy,rx,ry)` | Append ellipse subpath | | `Circle(cx,cy,r)` | Append circle subpath | +| `Arc(cx,cy,rx,ry,start,sweep)` | Append elliptical arc | +| `Sector(cx,cy,rx,ry,start,sweep)` | Append closed elliptical sector | | `Polyline(points[])` | Append open polyline (≥ 2 points) | | `Polygon(points[])` | Append closed polygon (≥ 3 points) | | `Fill(hexColor)` | Set fill paint | diff --git a/samples/TerraPDF.Sample/Program.cs b/samples/TerraPDF.Sample/Program.cs index a905b13..076cb8b 100644 --- a/samples/TerraPDF.Sample/Program.cs +++ b/samples/TerraPDF.Sample/Program.cs @@ -9,11 +9,11 @@ string sampleDir = args.Length > 0 ? args[0] : Path.Combine(desktop, "SamplePDF"); Directory.CreateDirectory(sampleDir); -string imgDir = AppContext.BaseDirectory; // images live next to the exe +string imgDir = AppContext.BaseDirectory; // images live next to the exe string headerImg = Path.Combine(imgDir, "header_logo.png"); -string smallImg = Path.Combine(imgDir, "small_logo.jpg"); +string smallImg = Path.Combine(imgDir, "small_logo.jpg"); -Console.WriteLine("TerraPDF Samples - generating seventeen PDFs to Desktop..."); +Console.WriteLine("TerraPDF Samples - generating eighteen PDFs to Desktop..."); Console.WriteLine(); SimpleReport.Generate(Path.Combine(sampleDir, "01_simple_report.pdf")); @@ -36,6 +36,7 @@ TableSpansShowcase.Generate(Path.Combine(sampleDir, "16_table_spans_showcase.pdf")); FontSubsettingShowcase.Generate(Path.Combine(sampleDir, "17_font_subsetting_showcase.pdf"), Path.Combine(imgDir, "Fonts", "Lato-Regular.ttf"), Path.Combine(imgDir, "Fonts", "NotoSansDevanagari-Regular.ttf")); +CanvasMediaShowcase.Generate(Path.Combine(sampleDir, "18_canvas_media_showcase.pdf"), smallImg); Console.WriteLine(); Console.WriteLine("Done."); diff --git a/samples/TerraPDF.Sample/Samples/18_CanvasMediaShowcase.cs b/samples/TerraPDF.Sample/Samples/18_CanvasMediaShowcase.cs new file mode 100644 index 0000000..42557b6 --- /dev/null +++ b/samples/TerraPDF.Sample/Samples/18_CanvasMediaShowcase.cs @@ -0,0 +1,45 @@ +namespace TerraPDF.Sample.Samples; + +using TerraPDF.Core; +using TerraPDF.Helpers; + +internal static class CanvasMediaShowcase +{ + internal static void Generate(string path, string imagePath) + { + Document.Create(document => document.Page(page => + { + page.Size(PageSize.A4); + page.Margin(2, Unit.Centimetre); + page.DefaultTextStyle(style => style.FontSize(10)); + + page.Header().Text("Canvas Media Showcase") + .Bold().FontSize(20).FontColor(Color.Blue.Darken2); + + page.Content().PaddingTop(16).Canvas(520, canvas => + { + canvas.Text("Contain", 12, 18, Color.Blue.Darken2, 11, bold: true); + canvas.DrawRect(10, 28, 200, 120, Color.Grey.Lighten5, Color.Grey.Medium); + canvas.Image(imagePath, 10, 28, 200, 120, ImageFit.Contain); + + canvas.Text("Cover", 242, 18, Color.Blue.Darken2, 11, bold: true); + canvas.DrawRect(240, 28, 200, 120, Color.Grey.Lighten5, Color.Grey.Medium); + canvas.Image(imagePath, 240, 28, 200, 120, ImageFit.Cover); + + canvas.Line(10, 180, 440, 180, Color.Grey.Medium, 1, + dashPattern: [8, 4]); + canvas.Text("Rotated text", 80, 285, Color.Orange.Darken2, 22, + bold: true, angle: -35); + + canvas.FillPie(260, 215, 150, 110, 20, 230, Color.Blue.Lighten2, 0.75); + canvas.StrokePie(260, 215, 150, 110, 20, 230, Color.Blue.Darken2, 2); + canvas.Text("Elliptical sector", 270, 350, Color.Blue.Darken2, 11); + + canvas.Path(shape => shape + .Arc(125, 420, 95, 45, 195, 250) + .Stroke(Color.Green.Darken2, 3)); + canvas.Text("PathDescriptor.Arc", 55, 490, Color.Green.Darken2, 11); + }); + })).PublishPdf(path); + } +} diff --git a/src/TerraPDF/Core/PathDescriptor.cs b/src/TerraPDF/Core/PathDescriptor.cs index 90d4ca7..d8463a5 100644 --- a/src/TerraPDF/Core/PathDescriptor.cs +++ b/src/TerraPDF/Core/PathDescriptor.cs @@ -12,11 +12,11 @@ public sealed class PathDescriptor internal readonly List Commands = []; // Paint state - internal PdfColor? FillColor { get; private set; } + internal PdfColor? FillColor { get; private set; } internal PdfColor? StrokeColor { get; private set; } - internal double LineWidth { get; private set; } = 1; - internal bool EvenOddFill { get; private set; } - internal double PaintOpacity { get; private set; } = 1; + internal double LineWidth { get; private set; } = 1; + internal bool EvenOddFill { get; private set; } + internal double PaintOpacity { get; private set; } = 1; // ── Move / Line ───────────────────────────────────────────────────────── @@ -43,7 +43,7 @@ public PathDescriptor LineTo(double x, double y) public PathDescriptor CurveTo( double cx1, double cy1, double cx2, double cy2, - double x, double y) + double x, double y) { Commands.Add(new CurveToCmd(cx1, cy1, cx2, cy2, x, y)); return this; @@ -68,7 +68,7 @@ public PathDescriptor Rect(double x, double y, double width, double height) return MoveTo(x, y) .LineTo(x + width, y) .LineTo(x + width, y + height) - .LineTo(x, y + height) + .LineTo(x, y + height) .Close(); } @@ -87,9 +87,9 @@ public PathDescriptor Ellipse(double cx, double cy, double rx, double ry) double ky = ry * k; return MoveTo(cx + rx, cy) - .CurveTo(cx + rx, cy - ky, cx + kx, cy - ry, cx, cy - ry) + .CurveTo(cx + rx, cy - ky, cx + kx, cy - ry, cx, cy - ry) .CurveTo(cx - kx, cy - ry, cx - rx, cy - ky, cx - rx, cy) - .CurveTo(cx - rx, cy + ky, cx - kx, cy + ry, cx, cy + ry) + .CurveTo(cx - rx, cy + ky, cx - kx, cy + ry, cx, cy + ry) .CurveTo(cx + kx, cy + ry, cx + rx, cy + ky, cx + rx, cy) .Close(); } @@ -104,6 +104,77 @@ public PathDescriptor Circle(double cx, double cy, double radius) return Ellipse(cx, cy, radius, radius); } + /// + /// Appends an elliptical arc. Angles are degrees clockwise from the right-hand point, + /// matching the canvas top-left coordinate system. Negative sweeps run counter-clockwise, + /// a zero sweep adds no commands, and sweeps beyond one revolution are preserved. + /// + /// Horizontal coordinate of the ellipse centre in points. + /// Vertical coordinate of the ellipse centre in points. + /// Horizontal radius in points. + /// Vertical radius in points. + /// Start angle in degrees clockwise from the right-hand point. + /// Signed sweep angle in degrees. + /// or is zero or negative. + public PathDescriptor Arc(double cx, double cy, double rx, double ry, + double startAngle, double sweepAngle) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rx); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(ry); + if (sweepAngle == 0) return this; + + double start = startAngle * Math.PI / 180.0; + double remaining = sweepAngle * Math.PI / 180.0; + double stepLimit = Math.PI / 2; + double step = Math.CopySign(Math.Min(Math.Abs(remaining), stepLimit), remaining); + + double x = cx + rx * Math.Cos(start); + double y = cy + ry * Math.Sin(start); + MoveTo(x, y); + + while (Math.Abs(remaining) > 1e-12) + { + step = Math.CopySign(Math.Min(Math.Abs(remaining), stepLimit), remaining); + double end = start + step; + double tangent = 4.0 / 3.0 * Math.Tan(step / 4.0); + double endX = cx + rx * Math.Cos(end); + double endY = cy + ry * Math.Sin(end); + double control1X = x - tangent * rx * Math.Sin(start); + double control1Y = y + tangent * ry * Math.Cos(start); + double control2X = endX + tangent * rx * Math.Sin(end); + double control2Y = endY - tangent * ry * Math.Cos(end); + + CurveTo(control1X, control1Y, control2X, control2Y, endX, endY); + x = endX; + y = endY; + start = end; + remaining -= step; + } + + return this; + } + + /// + /// Appends a closed elliptical sector by drawing an arc, connecting its end to the + /// centre, and closing the subpath. A zero sweep adds no commands. + /// + /// Horizontal coordinate of the ellipse centre in points. + /// Vertical coordinate of the ellipse centre in points. + /// Horizontal radius in points. + /// Vertical radius in points. + /// Start angle in degrees clockwise from the right-hand point. + /// Signed sweep angle in degrees; values beyond one revolution are preserved. + /// or is zero or negative. + public PathDescriptor Sector(double cx, double cy, double rx, double ry, + double startAngle, double sweepAngle) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rx); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(ry); + if (sweepAngle == 0) return this; + Arc(cx, cy, rx, ry, startAngle, sweepAngle); + return LineTo(cx, cy).Close(); + } + /// /// Appends a polyline (open path) through all in order. /// Points are (x, y) pairs. @@ -163,7 +234,7 @@ public PathDescriptor Stroke(string hexColor, double lineWidth = 1) ArgumentException.ThrowIfNullOrWhiteSpace(hexColor); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineWidth); StrokeColor = PdfColor.FromHex(hexColor); - LineWidth = lineWidth; + LineWidth = lineWidth; return this; } diff --git a/src/TerraPDF/Core/VectorCanvas.cs b/src/TerraPDF/Core/VectorCanvas.cs index 0a9f772..4c9093f 100644 --- a/src/TerraPDF/Core/VectorCanvas.cs +++ b/src/TerraPDF/Core/VectorCanvas.cs @@ -1,8 +1,28 @@ using TerraPDF.Drawing; +using TerraPDF.Elements; using TerraPDF.Helpers; namespace TerraPDF.Core; +/// Controls how a canvas image is fitted into its target rectangle. +public enum ImageFit +{ + /// Scales the image independently on each axis to fill the target rectangle. + Stretch, + + /// Preserves the aspect ratio, centres the image, and keeps it entirely inside the target rectangle. + Contain, + + /// Preserves the aspect ratio, centres the image, fills the target rectangle, and clips overflow. + Cover, + + /// Preserves the aspect ratio, anchors the image at the top-left, fills the target rectangle, and clips overflow. + CoverTopLeft, + + /// Draws the image at its natural 96-DPI size from the top-left and clips it to the target rectangle. + CropTopLeft, +} + /// /// Fluent vector-graphics canvas. Returned by IContainer.Canvas(…). /// Coordinates are in PDF points with a top-left origin relative to the @@ -24,6 +44,18 @@ namespace TerraPDF.Core; /// public sealed class VectorCanvas { + /// Returns an image's natural size in PDF points, assuming 96 DPI for pixel data. + /// Raw PNG or JPEG file bytes. + /// The image width and height in PDF points. + /// is . + /// The data is empty or the format is unsupported. + public static (double Width, double Height) GetImageSizeInPoints(byte[] imageData) + { + ArgumentNullException.ThrowIfNull(imageData); + var image = new ImageElement(imageData); + return (image.PixelSize.Width * 72d / 96d, image.PixelSize.Height * 72d / 96d); + } + // ----------------------------------------------------------------------- // Internal drawing command records // ----------------------------------------------------------------------- @@ -35,11 +67,13 @@ internal abstract record DrawCommand; internal sealed record DrawLineCmd( double X1, double Y1, double X2, double Y2, - string HexColor, double LineWidth, double Opacity) : DrawCommand; + string HexColor, double LineWidth, double Opacity, + double[]? DashPattern, double DashPhase) : DrawCommand; internal sealed record DrawRectCmd( double X, double Y, double W, double H, - string? FillHex, string? StrokeHex, double LineWidth, double Opacity) : DrawCommand; + string? FillHex, string? StrokeHex, double LineWidth, double Opacity, + double[]? DashPattern, double DashPhase) : DrawCommand; internal sealed record DrawRoundedRectCmd( double X, double Y, double W, double H, double Radius, @@ -51,9 +85,12 @@ internal sealed record DrawEllipseCmd( internal sealed record DrawPathCmd(PathDescriptor Path) : DrawCommand; + internal sealed record DrawImageCmd( + byte[] Data, double X, double Y, double W, double H, ImageFit Fit) : DrawCommand; + internal sealed record DrawTextCmd( double X, double Y, string Text, string HexColor, double FontSize, - string? FontFamily, bool Bold, bool Italic, double Opacity) : DrawCommand; + string? FontFamily, bool Bold, bool Italic, double Opacity, double Angle) : DrawCommand; // ----------------------------------------------------------------------- // State @@ -62,7 +99,7 @@ internal sealed record DrawTextCmd( internal readonly List Commands = []; // The bounding box is injected at render time by CanvasElement. - internal double AllocatedWidth { get; set; } + internal double AllocatedWidth { get; set; } internal double AllocatedHeight { get; set; } // ----------------------------------------------------------------------- @@ -81,21 +118,34 @@ private static void ValidateOpacity(double opacity) ArgumentOutOfRangeException.ThrowIfGreaterThan(opacity, 1.0, nameof(opacity)); } + private static double[]? PrepareDashPattern(double[]? dashPattern, double dashPhase) + { + if (dashPattern is null) return null; + if (dashPattern.Length == 0 || dashPattern.Any(value => value < 0 || double.IsNaN(value) || double.IsInfinity(value)) || dashPattern.All(value => value == 0)) + throw new ArgumentException("Dash patterns must contain at least one positive, finite value.", nameof(dashPattern)); + if (double.IsNaN(dashPhase) || double.IsInfinity(dashPhase)) + throw new ArgumentOutOfRangeException(nameof(dashPhase)); + return dashPattern.ToArray(); + } + // ----------------------------------------------------------------------- // Line // ----------------------------------------------------------------------- /// Draws a straight line from (, ) /// to (, ). - /// is null or whitespace. - /// is zero or negative, or is outside [0, 1]. + /// contains alternating dash and gap lengths in points and is copied; produces a solid line. offsets the start within that pattern. + /// is null or whitespace, or is empty, contains an invalid value, or contains only zeros. + /// is zero or negative, is outside [0, 1], or is not finite. public VectorCanvas Line(double x1, double y1, double x2, double y2, - string hexColor = "#000000", double lineWidth = 1, double opacity = 1) + string hexColor = "#000000", double lineWidth = 1, double opacity = 1, + double[]? dashPattern = null, double dashPhase = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(hexColor); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineWidth); ValidateOpacity(opacity); - Commands.Add(new DrawLineCmd(x1, y1, x2, y2, hexColor, lineWidth, opacity)); + dashPattern = PrepareDashPattern(dashPattern, dashPhase); + Commands.Add(new DrawLineCmd(x1, y1, x2, y2, hexColor, lineWidth, opacity, dashPattern, dashPhase)); return this; } @@ -111,34 +161,40 @@ public VectorCanvas FillRect(double x, double y, double width, double height, { ArgumentException.ThrowIfNullOrWhiteSpace(hexColor); ValidateOpacity(opacity); - Commands.Add(new DrawRectCmd(x, y, width, height, hexColor, null, 0, opacity)); + Commands.Add(new DrawRectCmd(x, y, width, height, hexColor, null, 0, opacity, null, 0)); return this; } /// Draws a stroked (outline-only) rectangle. - /// is null or whitespace. - /// is zero or negative, or is outside [0, 1]. + /// contains alternating dash and gap lengths in points and is copied; produces a solid outline. offsets the start within that pattern. + /// is null or whitespace, or is empty, contains an invalid value, or contains only zeros. + /// is zero or negative, is outside [0, 1], or is not finite. public VectorCanvas StrokeRect(double x, double y, double width, double height, - string hexColor = "#000000", double lineWidth = 1, double opacity = 1) + string hexColor = "#000000", double lineWidth = 1, double opacity = 1, + double[]? dashPattern = null, double dashPhase = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(hexColor); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineWidth); ValidateOpacity(opacity); - Commands.Add(new DrawRectCmd(x, y, width, height, null, hexColor, lineWidth, opacity)); + Commands.Add(new DrawRectCmd(x, y, width, height, null, hexColor, lineWidth, opacity, + PrepareDashPattern(dashPattern, dashPhase), dashPhase)); return this; } /// Draws a filled and stroked rectangle. - /// Either color argument is null or whitespace. - /// is zero or negative, or is outside [0, 1]. + /// contains alternating dash and gap lengths in points and is copied; produces a solid outline. offsets the start within that pattern. + /// Either color argument is null or whitespace, or is empty, contains an invalid value, or contains only zeros. + /// is zero or negative, is outside [0, 1], or is not finite. public VectorCanvas DrawRect(double x, double y, double width, double height, - string fillHex = "#FFFFFF", string strokeHex = "#000000", double lineWidth = 1, double opacity = 1) + string fillHex = "#FFFFFF", string strokeHex = "#000000", double lineWidth = 1, double opacity = 1, + double[]? dashPattern = null, double dashPhase = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(fillHex); ArgumentException.ThrowIfNullOrWhiteSpace(strokeHex); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineWidth); ValidateOpacity(opacity); - Commands.Add(new DrawRectCmd(x, y, width, height, fillHex, strokeHex, lineWidth, opacity)); + Commands.Add(new DrawRectCmd(x, y, width, height, fillHex, strokeHex, lineWidth, opacity, + PrepareDashPattern(dashPattern, dashPhase), dashPhase)); return this; } @@ -325,20 +381,122 @@ public VectorCanvas Path(Action configure) /// or measure-and-fit — use to size or centre it /// yourself first. /// + /// rotates clockwise in degrees around the baseline point (, ). Negative values and values outside one revolution are accepted. /// or is null or whitespace. /// is zero or negative, or is outside [0, 1]. public VectorCanvas Text(string text, double x, double y, string hexColor = "#000000", double fontSize = 12, - string? fontFamily = null, bool bold = false, bool italic = false, double opacity = 1) + string? fontFamily = null, bool bold = false, bool italic = false, double opacity = 1, + double angle = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(text); ArgumentException.ThrowIfNullOrWhiteSpace(hexColor); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(fontSize); ValidateOpacity(opacity); - Commands.Add(new DrawTextCmd(x, y, text, hexColor, fontSize, fontFamily, bold, italic, opacity)); + Commands.Add(new DrawTextCmd(x, y, text, hexColor, fontSize, fontFamily, bold, italic, opacity, angle)); return this; } + /// Draws a PNG or JPEG image at an absolute canvas position. + /// Raw PNG or JPEG file bytes. The data is copied when this method is called. + /// Left edge in points relative to the canvas. + /// Top edge in points relative to the canvas. + /// Rendered width in points. + /// Rendered height in points. + /// How the source image is fitted into the target rectangle. + /// The data is empty or the format is unsupported. + /// A dimension is zero or negative. + public VectorCanvas Image(byte[] imageData, double x, double y, double width, double height, + ImageFit fit = ImageFit.Stretch) + { + ArgumentNullException.ThrowIfNull(imageData); + if (imageData.Length == 0) + throw new ArgumentException("Image data cannot be empty.", nameof(imageData)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + Elements.ImageElement.ValidateFormat(imageData); + Commands.Add(new DrawImageCmd(imageData.ToArray(), x, y, width, height, fit)); + return this; + } + + /// Draws a PNG or JPEG file at an absolute canvas position. + /// Path to a PNG or JPEG file. The format is detected from magic bytes rather than the extension. + /// Left edge in points relative to the canvas. + /// Top edge in points relative to the canvas. + /// Rendered width in points. + /// Rendered height in points. + /// How the source image is fitted into the target rectangle. + /// The path is invalid, the file is empty, or the format is unsupported. + /// The file cannot be read. + /// A dimension is zero or negative. + public VectorCanvas Image(string filePath, double x, double y, double width, double height, + ImageFit fit = ImageFit.Stretch) => + Image(File.ReadAllBytes(filePath), x, y, width, height, fit); + + /// Draws a PNG or JPEG stream at an absolute canvas position. + /// Readable PNG or JPEG stream. The remaining bytes are copied and the stream is left open. + /// Left edge in points relative to the canvas. + /// Top edge in points relative to the canvas. + /// Rendered width in points. + /// Rendered height in points. + /// How the source image is fitted into the target rectangle. + /// The stream is read from its current position but is not disposed by this method. + /// is . + /// The remaining stream data is empty or the format is unsupported. + /// A dimension is zero or negative. + public VectorCanvas Image(Stream imageStream, double x, double y, double width, double height, + ImageFit fit = ImageFit.Stretch) + { + ArgumentNullException.ThrowIfNull(imageStream); + using var buffer = new MemoryStream(); + imageStream.CopyTo(buffer); + return Image(buffer.ToArray(), x, y, width, height, fit); + } + + /// Draws a filled elliptical sector inside the specified rectangle. + /// is measured clockwise from the right-hand point. Negative values sweep counter-clockwise, zero draws nothing, and values beyond one revolution are preserved. + /// is null or whitespace. + /// A dimension is zero or negative, or is outside [0, 1]. + public VectorCanvas FillPie(double x, double y, double width, double height, + double startAngle, double sweepAngle, string fillHex = "#000000", double opacity = 1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fillHex); + ValidateOpacity(opacity); + return Path(path => path.Sector(x + width / 2, y + height / 2, width / 2, height / 2, + startAngle, sweepAngle).Fill(fillHex).Opacity(opacity)); + } + + /// Draws the outline of an elliptical sector inside the specified rectangle. + /// is measured clockwise from the right-hand point. Negative values sweep counter-clockwise, zero draws nothing, and values beyond one revolution are preserved. + /// is null or whitespace. + /// A dimension or is zero or negative, or is outside [0, 1]. + public VectorCanvas StrokePie(double x, double y, double width, double height, + double startAngle, double sweepAngle, string strokeHex = "#000000", + double lineWidth = 1, double opacity = 1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(strokeHex); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineWidth); + ValidateOpacity(opacity); + return Path(path => path.Sector(x + width / 2, y + height / 2, width / 2, height / 2, + startAngle, sweepAngle).Stroke(strokeHex, lineWidth).Opacity(opacity)); + } + + /// Draws a filled and stroked elliptical sector inside the specified rectangle. + /// is measured clockwise from the right-hand point. Negative values sweep counter-clockwise, zero draws nothing, and values beyond one revolution are preserved. + /// Either color argument is null or whitespace. + /// A dimension or is zero or negative, or is outside [0, 1]. + public VectorCanvas DrawPie(double x, double y, double width, double height, + double startAngle, double sweepAngle, string fillHex = "#FFFFFF", + string strokeHex = "#000000", double lineWidth = 1, double opacity = 1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fillHex); + ArgumentException.ThrowIfNullOrWhiteSpace(strokeHex); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineWidth); + ValidateOpacity(opacity); + return Path(path => path.Sector(x + width / 2, y + height / 2, width / 2, height / 2, + startAngle, sweepAngle).Fill(fillHex).Stroke(strokeHex, lineWidth).Opacity(opacity)); + } + /// /// Total advance width would occupy at /// in the same font would render it in — use this to right-align or diff --git a/src/TerraPDF/Drawing/PdfPage.cs b/src/TerraPDF/Drawing/PdfPage.cs index dacb3a3..45c36c7 100644 --- a/src/TerraPDF/Drawing/PdfPage.cs +++ b/src/TerraPDF/Drawing/PdfPage.cs @@ -12,12 +12,12 @@ internal sealed class PdfPage { private readonly StringBuilder _ops = new(); - public double Width { get; } + public double Width { get; } public double Height { get; } internal PdfPage(double width, double height) { - Width = width; + Width = width; Height = height; } @@ -28,11 +28,11 @@ internal PdfPage(double width, double height) // State inside the current text object. Font/colour are re-emitted only // when they change between ShowTextAt calls; the Td origin is tracked so // each positioning operator is a small relative move. - private string? _textFontAlias; - private double _textFontSize; + private string? _textFontAlias; + private double _textFontSize; private PdfColor? _textColor; - private double _textTdX; - private double _textTdY; + private double _textTdX; + private double _textTdY; /// /// Opens a text object (BT). All state is reset — the PDF text @@ -43,10 +43,10 @@ internal void BeginTextObject() { _ops.Append("BT\n"); _textFontAlias = null; - _textFontSize = 0; - _textColor = null; - _textTdX = 0; - _textTdY = 0; + _textFontSize = 0; + _textColor = null; + _textTdX = 0; + _textTdY = 0; } /// @@ -82,6 +82,39 @@ internal void ShowTextAtCustomFont(string text, double x, double y, double fontS _ops.Append(" Tj\n"); } + /// Shows text at a baseline rotated clockwise in the caller's top-left coordinate system. + internal void ShowTextAtRotated(string text, double x, double y, double fontSize, + PdfColor color, double angle, PdfFontFamily family = PdfFontFamily.Helvetica, + bool bold = false, bool italic = false) + { + string fontAlias = PdfFonts.Alias(family, bold, italic); + EmitFontAndColor(fontAlias, fontSize, color); + double radians = angle * Math.PI / 180.0; + double cos = Math.Cos(radians); + double sin = Math.Sin(radians); + double pdfX = Math.Round(x, 2); + double pdfY = Math.Round(Height - y, 2); + _ops.Append(CultureInfo.InvariantCulture, + $"{F(cos)} {F(-sin)} {F(sin)} {F(cos)} {F(pdfX)} {F(pdfY)} Tm\n"); + _ops.Append(CultureInfo.InvariantCulture, $"({EscapeForPdfString(text)}) Tj\n"); + } + + internal void ShowTextAtRotated(string text, double x, double y, double fontSize, + PdfColor color, double angle, TrueType.CustomFontVariant variant) + { + string fontAlias = GetOrAddCustomFontAlias(variant); + EmitFontAndColor(fontAlias, fontSize, color); + double radians = angle * Math.PI / 180.0; + double cos = Math.Cos(radians); + double sin = Math.Sin(radians); + double pdfX = Math.Round(x, 2); + double pdfY = Math.Round(Height - y, 2); + _ops.Append(CultureInfo.InvariantCulture, + $"{F(cos)} {F(-sin)} {F(sin)} {F(cos)} {F(pdfX)} {F(pdfY)} Tm\n"); + _ops.Append(EncodeIdentityHHex(text, variant)); + _ops.Append(" Tj\n"); + } + /// /// Emits the colour (rg), font (Tf), and position (Td) operators /// shared by every text-showing call, re-emitting colour/font only when they differ @@ -90,6 +123,19 @@ internal void ShowTextAtCustomFont(string text, double x, double y, double fontS /// operators they have in common. /// private void EmitFontColorAndPosition(string fontAlias, double fontSize, PdfColor color, double x, double y) + { + EmitFontAndColor(fontAlias, fontSize, color); + + // Td moves relative to the previous text-line origin. Deltas are taken + // between rounded absolute positions so rounding never accumulates. + double pdfX = Math.Round(x, 2); + double pdfY = Math.Round(Height - y, 2); + _ops.Append(CultureInfo.InvariantCulture, $"{F(pdfX - _textTdX)} {F(pdfY - _textTdY)} Td\n"); + _textTdX = pdfX; + _textTdY = pdfY; + } + + private void EmitFontAndColor(string fontAlias, double fontSize, PdfColor color) { if (_textColor is null || !_textColor.Value.Equals(color)) { @@ -101,16 +147,8 @@ private void EmitFontColorAndPosition(string fontAlias, double fontSize, PdfColo { _ops.Append(CultureInfo.InvariantCulture, $"/{fontAlias} {F(fontSize)} Tf\n"); _textFontAlias = fontAlias; - _textFontSize = fontSize; + _textFontSize = fontSize; } - - // Td moves relative to the previous text-line origin. Deltas are taken - // between rounded absolute positions so rounding never accumulates. - double pdfX = Math.Round(x, 2); - double pdfY = Math.Round(Height - y, 2); - _ops.Append(CultureInfo.InvariantCulture, $"{F(pdfX - _textTdX)} {F(pdfY - _textTdY)} Td\n"); - _textTdX = pdfX; - _textTdY = pdfY; } /// @@ -214,13 +252,44 @@ internal void EndOpacityScope(bool opened) if (opened) _ops.Append("Q\n"); } + private bool BeginDashScope(double[]? dashPattern, double dashPhase) + { + if (dashPattern is null) return false; + _ops.Append("q\n["); + for (var index = 0; index < dashPattern.Length; index++) + { + if (index > 0) _ops.Append(' '); + _ops.Append(F(dashPattern[index])); + } + _ops.Append(CultureInfo.InvariantCulture, $"] {F(dashPhase)} d\n"); + return true; + } + + private void EndDashScope(bool opened) + { + if (opened) _ops.Append("Q\n"); + } + // -------------------------------------------------------------- // Drawing operations (primitive overloads - used by new API) // -------------------------------------------------------------- internal void AddLine(double x1, double y1, double x2, double y2, - PdfColor color, double lineWidth = 1, double opacity = 1) + PdfColor color, double lineWidth = 1, double opacity = 1, + double[]? dashPattern = null, double dashPhase = 0) { + bool dashScope = dashPattern is not null; + if (dashScope) + { + _ops.Append("q\n"); + _ops.Append('['); + for (var index = 0; index < dashPattern!.Length; index++) + { + if (index > 0) _ops.Append(' '); + _ops.Append(F(dashPattern[index])); + } + _ops.Append(CultureInfo.InvariantCulture, $"] {F(dashPhase)} d\n"); + } bool scope = BeginOpacityScope(opacity); // Flip both endpoints from top-left to bottom-left origin double pdfY1 = Height - y1; @@ -231,6 +300,7 @@ internal void AddLine(double x1, double y1, double x2, double y2, _ops.Append(CultureInfo.InvariantCulture, $"{F(x2)} {F(pdfY2)} l\n"); _ops.Append("S\n"); EndOpacityScope(scope); + if (dashScope) _ops.Append("Q\n"); } /// Draws a filled rectangle (no border). @@ -256,7 +326,7 @@ internal void AddFilledRect(double x, double y, double w, double h, PdfColor fil internal void AddFilledRects(IEnumerable<(double X, double Y, double W, double H)> rects, PdfColor fillColor) { bool wroteColor = false; - bool wroteAny = false; + bool wroteAny = false; foreach (var (x, y, w, h) in rects) { if (w <= 0 || h <= 0) continue; @@ -274,8 +344,10 @@ internal void AddFilledRects(IEnumerable<(double X, double Y, double W, double H /// Draws a stroked (outline-only) rectangle. internal void AddStrokedRect(double x, double y, double w, double h, - PdfColor strokeColor, double lineWidth = 1, double opacity = 1) + PdfColor strokeColor, double lineWidth = 1, double opacity = 1, + double[]? dashPattern = null, double dashPhase = 0) { + bool dashScope = BeginDashScope(dashPattern, dashPhase); bool scope = BeginOpacityScope(opacity); double pdfY = Height - y - h; _ops.Append(CultureInfo.InvariantCulture, $"{F(lineWidth)} w\n"); @@ -283,12 +355,15 @@ internal void AddStrokedRect(double x, double y, double w, double h, _ops.Append(CultureInfo.InvariantCulture, $"{F(x)} {F(pdfY)} {F(w)} {F(h)} re\n"); _ops.Append("S\n"); EndOpacityScope(scope); + EndDashScope(dashScope); } /// Draws a filled-and-stroked rectangle. internal void AddRect(double x, double y, double w, double h, - PdfColor fillColor, PdfColor strokeColor, double lineWidth = 1, double opacity = 1) + PdfColor fillColor, PdfColor strokeColor, double lineWidth = 1, double opacity = 1, + double[]? dashPattern = null, double dashPhase = 0) { + bool dashScope = BeginDashScope(dashPattern, dashPhase); bool scope = BeginOpacityScope(opacity); double pdfY = Height - y - h; _ops.Append(CultureInfo.InvariantCulture, $"{F(lineWidth)} w\n"); @@ -297,6 +372,7 @@ internal void AddRect(double x, double y, double w, double h, _ops.Append(CultureInfo.InvariantCulture, $"{F(x)} {F(pdfY)} {F(w)} {F(h)} re\n"); _ops.Append("B\n"); EndOpacityScope(scope); + EndDashScope(dashScope); } // -------------------------------------------------------------- @@ -319,9 +395,9 @@ private void AppendRoundedRectPath(double x, double y, double w, double h, doubl r = Math.Min(r, Math.Min(w, h) / 2.0); // All coordinates in PDF bottom-left origin - double b = Height - y - h; // bottom Y in PDF coords - double t = Height - y; // top Y in PDF coords - double k = r * _bezierArcK; + double b = Height - y - h; // bottom Y in PDF coords + double t = Height - y; // top Y in PDF coords + double k = r * _bezierArcK; // Start at top-left corner, just right of the top-left arc _ops.Append(CultureInfo.InvariantCulture, $"{F(x + r)} {F(t)} m\n"); @@ -493,11 +569,11 @@ internal void PathLineTo(double x, double y) internal void PathCurveTo( double cx1, double cy1, double cx2, double cy2, - double x, double y) + double x, double y) { double pdfCy1 = Height - cy1; double pdfCy2 = Height - cy2; - double pdfY = Height - y; + double pdfY = Height - y; _ops.Append(CultureInfo.InvariantCulture, $"{F(cx1)} {F(pdfCy1)} {F(cx2)} {F(pdfCy2)} {F(x)} {F(pdfY)} c\n"); } @@ -596,6 +672,16 @@ internal void DrawImage(string alias, byte[] data, int imgW, int imgH, _ops.Append("Q\n"); } + internal void BeginClip(double x, double y, double width, double height) + { + double pdfY = Height - y - height; + _ops.Append("q\n"); + _ops.Append(CultureInfo.InvariantCulture, + $"{F(x)} {F(pdfY)} {F(width)} {F(height)} re W n\n"); + } + + internal void EndClip() => _ops.Append("Q\n"); + // -------------------------------------------------------------- // Serialization // -------------------------------------------------------------- diff --git a/src/TerraPDF/Drawing/PngDecoder.cs b/src/TerraPDF/Drawing/PngDecoder.cs index 8316529..c1c46b5 100644 --- a/src/TerraPDF/Drawing/PngDecoder.cs +++ b/src/TerraPDF/Drawing/PngDecoder.cs @@ -5,10 +5,10 @@ namespace TerraPDF.Drawing; /// /// Minimal pure-C# PNG decoder that produces flat 24-bit RGB pixel data plus an /// optional alpha channel. -/// Supports color types 2 (RGB), 6 (RGBA), and 3 (indexed/palette), +/// Supports color types 2 (RGB), 4 (grayscale+alpha), 6 (RGBA), and 3 (indexed/palette), /// all at bit depth 8. Interlaced PNGs are not supported. -/// Alpha is extracted only from colour type 6; indexed transparency (tRNS) is -/// not supported and decodes as opaque. +/// Alpha is extracted from colour types 4 and 6; indexed transparency (tRNS) +/// is not supported and decodes as opaque. /// internal static class PngDecoder { @@ -47,16 +47,16 @@ internal static byte[] Decode(Stream stream, out int width, out int height, out switch (chunkType) { case "IHDR": - imgWidth = ReadBigEndianInt32(data, 0); + imgWidth = ReadBigEndianInt32(data, 0); imgHeight = ReadBigEndianInt32(data, 4); - bitDepth = data[8]; + bitDepth = data[8]; colorType = data[9]; if (bitDepth != 8) throw new NotSupportedException( $"PNG bit depth {bitDepth} is not supported; only 8-bit PNGs are accepted."); - if (colorType != 2 && colorType != 3 && colorType != 6) + if (colorType != 2 && colorType != 3 && colorType != 4 && colorType != 6) throw new NotSupportedException( - $"PNG color type {colorType} is not supported; use RGB (2), RGBA (6), or indexed (3)."); + $"PNG color type {colorType} is not supported; use RGB (2), grayscale+alpha (4), RGBA (6), or indexed (3)."); if (data[12] != 0) // interlace method throw new NotSupportedException("Interlaced PNGs are not supported."); break; @@ -74,22 +74,22 @@ internal static byte[] Decode(Stream stream, out int width, out int height, out goto parseDone; } } - parseDone: + parseDone: - width = imgWidth; + width = imgWidth; height = imgHeight; // Concatenate all IDAT chunks into one buffer int totalLen = idatChunks.Sum(c => c.Length); - var idatAll = new byte[totalLen]; - int pos = 0; + var idatAll = new byte[totalLen]; + int pos = 0; foreach (var chunk in idatChunks) { chunk.CopyTo(idatAll, pos); pos += chunk.Length; } // Decompress: idatAll is a zlib stream (2-byte header + deflate data + 4-byte Adler32) // ZLibStream handles the zlib framing transparently. using var compressedMs = new MemoryStream(idatAll); - using var zlib = new ZLibStream(compressedMs, CompressionMode.Decompress); - using var rawMs = new MemoryStream(); + using var zlib = new ZLibStream(compressedMs, CompressionMode.Decompress); + using var rawMs = new MemoryStream(); zlib.CopyTo(rawMs); byte[] raw = rawMs.ToArray(); @@ -103,31 +103,32 @@ internal static byte[] Decode(Stream stream, out int width, out int height, out private static byte[] Unfilter(byte[] raw, int width, int height, int colorType, byte[]? palette, out byte[]? alpha) { - // Alpha channel is only present in RGBA (type 6); collected per pixel, + // Alpha channel is present in grayscale+alpha (type 4) and RGBA (type 6); collected per pixel, // returned as null when the image turns out to be fully opaque. - byte[]? alphaBytes = colorType == 6 ? new byte[width * height] : null; + byte[]? alphaBytes = colorType == 4 || colorType == 6 ? new byte[width * height] : null; bool anyTransparency = false; // Bytes per source pixel in the filtered data stream int srcBpp = colorType switch { 2 => 3, // RGB + 4 => 2, // grayscale + alpha 6 => 4, // RGBA 3 => 1, // indexed (1 byte palette index) _ => 3, }; int rowStride = width * srcBpp + 1; // +1 for the filter-type byte at the start of each row - var rgb = new byte[width * height * 3]; + var rgb = new byte[width * height * 3]; int dstOffset = 0; var prevRow = new byte[width * srcBpp]; // previous unfiltered row (all-zeros for first row) for (int y = 0; y < height; y++) { - int rowStart = y * rowStride; - byte filter = raw[rowStart]; - var row = new byte[width * srcBpp]; + int rowStart = y * rowStride; + byte filter = raw[rowStart]; + var row = new byte[width * srcBpp]; // Copy raw (still-filtered) bytes into row buffer Buffer.BlockCopy(raw, rowStart + 1, row, 0, row.Length); @@ -153,6 +154,15 @@ private static byte[] Unfilter(byte[] raw, int width, int height, int colorType, alphaBytes![y * width + x] = a; if (a != 0xFF) anyTransparency = true; break; + case 4: // Grayscale + alpha - expand gray to RGB and collect alpha + byte gray = row[x * 2]; + rgb[dstOffset++] = gray; + rgb[dstOffset++] = gray; + rgb[dstOffset++] = gray; + byte grayAlpha = row[x * 2 + 1]; + alphaBytes![y * width + x] = grayAlpha; + if (grayAlpha != 0xFF) anyTransparency = true; + break; case 3: // Indexed - look up colour in palette int pi = row[x] * 3; rgb[dstOffset++] = palette![pi]; @@ -188,7 +198,7 @@ private static void ApplyInverseFilter(byte[] row, byte[] prev, byte filter, int case 3: // Average - predicted from floor((left + above) / 2) for (int i = 0; i < row.Length; i++) { - int left = i >= bpp ? row[i - bpp] : 0; + int left = i >= bpp ? row[i - bpp] : 0; int above = prev[i]; row[i] = (byte)(row[i] + (left + above) / 2); } @@ -197,8 +207,8 @@ private static void ApplyInverseFilter(byte[] row, byte[] prev, byte filter, int case 4: // Paeth - predicted by the Paeth predictor function for (int i = 0; i < row.Length; i++) { - int left = i >= bpp ? row[i - bpp] : 0; - int above = prev[i]; + int left = i >= bpp ? row[i - bpp] : 0; + int above = prev[i]; int upperLeft = i >= bpp ? prev[i - bpp] : 0; row[i] = (byte)(row[i] + PaethPredictor(left, above, upperLeft)); } @@ -226,12 +236,12 @@ private static int ReadBigEndianInt32(byte[] buf, int offset = 0) => // Paeth predictor: selects the nearest of left, above, or upper-left private static int PaethPredictor(int a, int b, int c) { - int p = a + b - c; + int p = a + b - c; int pa = Math.Abs(p - a); int pb = Math.Abs(p - b); int pc = Math.Abs(p - c); if (pa <= pb && pa <= pc) return a; - if (pb <= pc) return b; + if (pb <= pc) return b; return c; } } diff --git a/src/TerraPDF/Elements/CanvasElement.cs b/src/TerraPDF/Elements/CanvasElement.cs index ffa4ccc..999e833 100644 --- a/src/TerraPDF/Elements/CanvasElement.cs +++ b/src/TerraPDF/Elements/CanvasElement.cs @@ -12,7 +12,7 @@ namespace TerraPDF.Elements; internal sealed class CanvasElement : Element { private readonly VectorCanvas _canvas; - private readonly double _height; + private readonly double _height; internal CanvasElement(VectorCanvas canvas, double height) { @@ -26,7 +26,7 @@ internal override ElementSize Measure(double w, double h, TextStyle? defaultStyl int totalPagesHint = DefaultTotalPagesHint) { // Inject allocated size so Grid() can use it during the configure callback - _canvas.AllocatedWidth = w; + _canvas.AllocatedWidth = w; _canvas.AllocatedHeight = _height; return new ElementSize(w, Math.Min(_height, h)); } @@ -35,7 +35,7 @@ internal override ElementSize Measure(double w, double h, TextStyle? defaultStyl internal override void Draw(DrawingContext ctx) { - _canvas.AllocatedWidth = ctx.Width; + _canvas.AllocatedWidth = ctx.Width; _canvas.AllocatedHeight = _height; foreach (var cmd in _canvas.Commands) @@ -46,7 +46,8 @@ internal override void Draw(DrawingContext ctx) ctx.Page.AddLine( ctx.X + lc.X1, ctx.Y + lc.Y1, ctx.X + lc.X2, ctx.Y + lc.Y2, - PdfColor.FromHex(lc.HexColor), lc.LineWidth, lc.Opacity); + PdfColor.FromHex(lc.HexColor), lc.LineWidth, lc.Opacity, + lc.DashPattern, lc.DashPhase); break; case VectorCanvas.DrawRectCmd rc: @@ -65,6 +66,10 @@ internal override void Draw(DrawingContext ctx) DrawPath(ctx, pc.Path); break; + case VectorCanvas.DrawImageCmd ic: + DrawImage(ctx, ic); + break; + case VectorCanvas.DrawTextCmd tc: DrawText(ctx, tc); break; @@ -81,11 +86,13 @@ private static void DrawRect(DrawingContext ctx, VectorCanvas.DrawRectCmd rc) if (rc.FillHex is not null && rc.StrokeHex is not null) ctx.Page.AddRect(ax, ay, rc.W, rc.H, - PdfColor.FromHex(rc.FillHex), PdfColor.FromHex(rc.StrokeHex), rc.LineWidth, rc.Opacity); + PdfColor.FromHex(rc.FillHex), PdfColor.FromHex(rc.StrokeHex), rc.LineWidth, rc.Opacity, + rc.DashPattern, rc.DashPhase); else if (rc.FillHex is not null) ctx.Page.AddFilledRect(ax, ay, rc.W, rc.H, PdfColor.FromHex(rc.FillHex), rc.Opacity); else if (rc.StrokeHex is not null) - ctx.Page.AddStrokedRect(ax, ay, rc.W, rc.H, PdfColor.FromHex(rc.StrokeHex), rc.LineWidth, rc.Opacity); + ctx.Page.AddStrokedRect(ax, ay, rc.W, rc.H, PdfColor.FromHex(rc.StrokeHex), rc.LineWidth, rc.Opacity, + rc.DashPattern, rc.DashPhase); } private static void DrawRoundedRect(DrawingContext ctx, VectorCanvas.DrawRoundedRectCmd rr) @@ -145,7 +152,7 @@ private static void DrawPath(DrawingContext ctx, PathDescriptor pd) ctx.Page.PathCurveTo( ctx.X + c.Cx1, ctx.Y + c.Cy1, ctx.X + c.Cx2, ctx.Y + c.Cy2, - ctx.X + c.X, ctx.Y + c.Y); + ctx.X + c.X, ctx.Y + c.Y); break; case ClosePathCmd: ctx.Page.PathClose(); @@ -164,10 +171,26 @@ private static void DrawText(DrawingContext ctx, VectorCanvas.DrawTextCmd tc) bool opacityScope = ctx.Page.BeginOpacityScope(tc.Opacity); ctx.Page.BeginTextObject(); if (font.IsCustom) - ctx.Page.ShowTextAtCustomFont(tc.Text, ctx.X + tc.X, ctx.Y + tc.Y, tc.FontSize, color, font.Custom!); + { + if (tc.Angle == 0) + ctx.Page.ShowTextAtCustomFont(tc.Text, ctx.X + tc.X, ctx.Y + tc.Y, tc.FontSize, color, font.Custom!); + else + ctx.Page.ShowTextAtRotated(tc.Text, ctx.X + tc.X, ctx.Y + tc.Y, tc.FontSize, color, tc.Angle, font.Custom!); + } else - ctx.Page.ShowTextAt(tc.Text, ctx.X + tc.X, ctx.Y + tc.Y, tc.FontSize, color, font.StandardFamily, tc.Bold, tc.Italic); + { + if (tc.Angle == 0) + ctx.Page.ShowTextAt(tc.Text, ctx.X + tc.X, ctx.Y + tc.Y, tc.FontSize, color, font.StandardFamily, tc.Bold, tc.Italic); + else + ctx.Page.ShowTextAtRotated(tc.Text, ctx.X + tc.X, ctx.Y + tc.Y, tc.FontSize, color, tc.Angle, font.StandardFamily, tc.Bold, tc.Italic); + } ctx.Page.EndTextObject(); ctx.Page.EndOpacityScope(opacityScope); } + + private static void DrawImage(DrawingContext ctx, VectorCanvas.DrawImageCmd ic) + { + var image = new ImageElement(ic.Data); + image.DrawAt(ctx.Page, ctx.X + ic.X, ctx.Y + ic.Y, ic.W, ic.H, ic.Fit); + } } diff --git a/src/TerraPDF/Elements/ImageElement.cs b/src/TerraPDF/Elements/ImageElement.cs index b301e9c..18059bd 100644 --- a/src/TerraPDF/Elements/ImageElement.cs +++ b/src/TerraPDF/Elements/ImageElement.cs @@ -1,3 +1,4 @@ +using TerraPDF.Core; using TerraPDF.Drawing; using TerraPDF.Helpers; @@ -21,15 +22,17 @@ internal sealed class ImageElement : Element // Static counter used to generate a unique PDF resource alias per ImageElement instance private static int _aliasCounter; - private readonly byte[] _data; // PNG: decoded RGB pixels | JPEG: raw file bytes + private readonly byte[] _data; // PNG: decoded RGB pixels | JPEG: raw file bytes private readonly byte[]? _alpha; // PNG RGBA: 8-bit alpha channel (null when opaque / JPEG) - private readonly int _imgWidth; - private readonly int _imgHeight; - private readonly string _alias; // PDF XObject resource name, e.g. "Im3" - private readonly bool _isJpeg; - private readonly int _components; // colour component count (used to choose ColorSpace) + private readonly int _imgWidth; + private readonly int _imgHeight; + private readonly string _alias; // PDF XObject resource name, e.g. "Im3" + private readonly bool _isJpeg; + private readonly int _components; // colour component count (used to choose ColorSpace) private readonly double? _maxWidth; // optional width cap in PDF points; null = fill available width + internal (int Width, int Height) PixelSize => (_imgWidth, _imgHeight); + /// Path to a PNG or JPEG file. /// Maximum rendered width in PDF points; null = fill available width. /// The data is neither PNG nor JPEG. @@ -49,19 +52,19 @@ internal ImageElement(byte[] imageData, double? width = null) { // JPEG: read dimensions only - raw bytes are passed straight to PdfPage using var ms = new MemoryStream(imageData, writable: false); - var info = JpegInfo.Read(ms); - _imgWidth = info.Width; - _imgHeight = info.Height; + var info = JpegInfo.Read(ms); + _imgWidth = info.Width; + _imgHeight = info.Height; _components = info.Components; - _data = imageData; - _isJpeg = true; + _data = imageData; + _isJpeg = true; } else if (IsPngData(imageData)) { using var ms = new MemoryStream(imageData, writable: false); - _data = PngDecoder.Decode(ms, out _imgWidth, out _imgHeight, out _alpha); + _data = PngDecoder.Decode(ms, out _imgWidth, out _imgHeight, out _alpha); _components = 3; - _isJpeg = false; + _isJpeg = false; } else { @@ -81,6 +84,14 @@ private static bool IsPngData(byte[] d) => private static bool IsJpegData(byte[] d) => d.Length >= 2 && d[0] == 0xFF && d[1] == 0xD8; + internal static void ValidateFormat(byte[] imageData) + { + if (!IsPngData(imageData) && !IsJpegData(imageData)) + throw new ArgumentException( + "Image data is not a recognised PNG or JPEG (checked by magic bytes). " + + "Only PNG and JPEG images are supported.", nameof(imageData)); + } + // -- Sizing ---------------------------------------------------- /// @@ -123,4 +134,51 @@ internal override void Draw(DrawingContext ctx) ctx.X, ctx.Y, drawW, drawH, _isJpeg, _components, _alpha); } + + internal void DrawAt(PdfPage page, double x, double y, double width, double height, ImageFit fit) + { + double imageAspect = (double)_imgWidth / _imgHeight; + double targetAspect = width / height; + double drawWidth = width; + double drawHeight = height; + double drawX = x; + double drawY = y; + + if (fit == ImageFit.Contain) + { + if (imageAspect > targetAspect) + drawHeight = width / imageAspect; + else + drawWidth = height * imageAspect; + drawX += (width - drawWidth) / 2; + drawY += (height - drawHeight) / 2; + } + else if (fit is ImageFit.Cover or ImageFit.CoverTopLeft or ImageFit.CropTopLeft) + { + if (fit is ImageFit.Cover or ImageFit.CoverTopLeft) + { + if (imageAspect > targetAspect) + drawWidth = height * imageAspect; + else + drawHeight = width / imageAspect; + if (fit == ImageFit.Cover) + { + drawX += (width - drawWidth) / 2; + drawY += (height - drawHeight) / 2; + } + } + else + { + drawWidth = _imgWidth * 72d / 96d; + drawHeight = _imgHeight * 72d / 96d; + } + page.BeginClip(x, y, width, height); + } + + page.DrawImage(_alias, _data, _imgWidth, _imgHeight, + drawX, drawY, drawWidth, drawHeight, _isJpeg, _components, _alpha); + + if (fit is ImageFit.Cover or ImageFit.CoverTopLeft or ImageFit.CropTopLeft) + page.EndClip(); + } } diff --git a/src/TerraPDF/TerraPDF.csproj b/src/TerraPDF/TerraPDF.csproj index fe5eb14..1aec2a8 100644 --- a/src/TerraPDF/TerraPDF.csproj +++ b/src/TerraPDF/TerraPDF.csproj @@ -60,7 +60,7 @@ - + diff --git a/tests/TerraPDF.Tests/CanvasImageTests.cs b/tests/TerraPDF.Tests/CanvasImageTests.cs new file mode 100644 index 0000000..53ec8d5 --- /dev/null +++ b/tests/TerraPDF.Tests/CanvasImageTests.cs @@ -0,0 +1,163 @@ +using TerraPDF.Core; +using TerraPDF.Helpers; +using Xunit; + +namespace TerraPDF.Tests; + +public sealed class CanvasImageTests +{ + private static byte[] Render(Action draw) => + Document.Create(document => document.Page(page => + { + page.Size(PageSize.A4); + page.Margin(0); + page.Content().Canvas(100, draw); + })).PublishPdf(); + + [Fact] + public void ImageFromBytesEmitsImageObjectAndRequestedMatrix() + { + byte[] pdf = Render(canvas => canvas.Image(TestImageData.MakePng(4, 5, false, 255), 12, 18, 40, 30)); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Contains("/Subtype /Image", content); + Assert.Contains("40.00 0 0 30.00 12.00", content); + Assert.Contains("/Im", content); + Assert.Contains(" Do", content); + } + + [Fact] + public void JpegImageEmitsDctDecodeResource() + { + byte[] pdf = Render(canvas => + canvas.Image(TestImageData.MakeJpegHeaderOnly(7, 9), 5, 6, 30, 20)); + string raw = System.Text.Encoding.Latin1.GetString(pdf); + + Assert.Contains("/Subtype /Image", raw); + Assert.Contains("/Filter /DCTDecode", raw); + } + + [Fact] + public void SameCanvasImageIsReusedAcrossPages() + { + byte[] image = TestImageData.MakePng(2, 2, false, 255); + byte[] pdf = Document.Create(document => + { + document.Page(page => page.Content().Canvas(100, canvas => + canvas.Image(image, 0, 0, 20, 20))); + document.Page(page => page.Content().Canvas(100, canvas => + canvas.Image(image, 10, 10, 30, 30))); + }).PublishPdf(); + string raw = System.Text.Encoding.Latin1.GetString(pdf); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Equal(1, raw.Split("/Subtype /Image").Length - 1); + Assert.Equal(2, content.Split(" Do").Length - 1); + } + + [Fact] + public void TransparentImageFromStreamEmitsSoftMaskAndKeepsStreamOpen() + { + using var stream = new MemoryStream(TestImageData.MakePng(3, 4, true, 128)); + byte[] pdf = Render(canvas => canvas.Image(stream, 1, 2, 20, 25)); + + Assert.True(stream.CanRead); + Assert.Contains("/SMask", System.Text.Encoding.Latin1.GetString(pdf)); + } + + [Fact] + public void ContainPreservesAspectRatioAndCentersImage() + { + byte[] pdf = Render(canvas => + canvas.Image(TestImageData.MakePng(4, 2, false, 255), 0, 0, 40, 30, ImageFit.Contain)); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Matches(@"40\.00 0 0 20\.00 0\.00 [0-9.]+ cm", content); + } + + [Fact] + public void CoverClipsToTargetRectangleAndRestoresGraphicsState() + { + byte[] pdf = Render(canvas => + { + canvas.Image(TestImageData.MakePng(4, 2, false, 255), 10, 15, 40, 30, ImageFit.Cover); + canvas.FillRect(0, 0, 5, 5, "#FF0000"); + }); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Matches(@"10\.00 [0-9.]+ 40\.00 30\.00 re W n", content); + Assert.Matches(@"60\.00 0 0 30\.00 0\.00 [0-9.]+ cm", content); + Assert.Contains("Q\n1.0000 0.0000 0.0000 rg", content); + } + + [Fact] + public void CoverTopLeftClipsWithoutCenteringImage() + { + byte[] pdf = Render(canvas => + { + canvas.Image(TestImageData.MakePng(4, 2, false, 255), 10, 15, 40, 30, ImageFit.CoverTopLeft); + canvas.FillRect(0, 0, 5, 5, "#FF0000"); + }); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Matches(@"60\.00 0 0 30\.00 10\.00 [0-9.]+ cm", content); + Assert.Contains("Q\n1.0000 0.0000 0.0000 rg", content); + } + + [Fact] + public void CropTopLeftKeepsNaturalSizeAndClipsRightAndBottom() + { + byte[] pdf = Render(canvas => + canvas.Image(TestImageData.MakePng(200, 200, false, 255), 10, 15, 135, 90, ImageFit.CropTopLeft)); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Matches(@"10\.00 [0-9.]+ 135\.00 90\.00 re W n", content); + Assert.Matches(@"150\.00 0 0 150\.00 10\.00 [0-9.]+ cm", content); + } + + [Fact] + public void PortraitImageCanUseCoverFit() + { + byte[] pdf = Render(canvas => + canvas.Image(TestImageData.MakePng(2, 4, false, 255), 5, 6, 30, 20, ImageFit.Cover)); + string content = PdfTestUtils.InflatedText(pdf); + + Assert.Matches(@"5\.00 [0-9.]+ 30\.00 20\.00 re W n", content); + Assert.Matches(@"30\.00 0 0 60\.00 [0-9.-]+ [0-9.]+ cm", content); + } + + [Fact] + public void ImageCanBeLoadedFromFile() + { + string path = Path.Combine(Path.GetTempPath(), $"terrapdf-{Guid.NewGuid():N}.png"); + File.WriteAllBytes(path, TestImageData.MakePng(2, 3, false, 255)); + try + { + byte[] pdf = Render(canvas => canvas.Image(path, 0, 0, 12, 18)); + Assert.Contains("/Subtype /Image", System.Text.Encoding.Latin1.GetString(pdf)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void EmptyOrUnsupportedCanvasImageThrows() + { + var canvas = new VectorCanvas(); + + Assert.Throws(() => canvas.Image([], 0, 0, 10, 10)); + Assert.Throws(() => canvas.Image([1, 2, 3], 0, 0, 10, 10)); + } + + [Fact] + public void NonPositiveCanvasImageDimensionsThrow() + { + var canvas = new VectorCanvas(); + byte[] png = TestImageData.MakePng(1, 1, false, 255); + + Assert.Throws(() => canvas.Image(png, 0, 0, 0, 10)); + Assert.Throws(() => canvas.Image(png, 0, 0, 10, -1)); + } +} diff --git a/tests/TerraPDF.Tests/CanvasPieTests.cs b/tests/TerraPDF.Tests/CanvasPieTests.cs new file mode 100644 index 0000000..9754ea6 --- /dev/null +++ b/tests/TerraPDF.Tests/CanvasPieTests.cs @@ -0,0 +1,59 @@ +using TerraPDF.Core; +using TerraPDF.Helpers; +using Xunit; + +namespace TerraPDF.Tests; + +public sealed class CanvasPieTests +{ + private static byte[] Render(Action draw) => + Document.Create(document => document.Page(page => + { + page.Size(PageSize.A4); + page.Margin(0); + page.Content().Canvas(120, draw); + })).PublishPdf(); + + [Fact] + public void FilledQuarterPieEmitsBezierPathAndFillOperator() + { + string content = PdfTestUtils.InflatedText( + Render(canvas => canvas.FillPie(10, 20, 40, 30, 0, 90))); + + Assert.Contains(" c\n", content); + Assert.Contains("h\n", content); + Assert.Contains("f\n", content); + } + + [Fact] + public void DrawPieEmitsStrokeAndFillForNegativeSweep() + { + string content = PdfTestUtils.InflatedText( + Render(canvas => canvas.DrawPie(10, 20, 40, 30, 180, -270))); + + Assert.Contains(" c\n", content); + Assert.Contains("B\n", content); + } + + [Fact] + public void PieRejectsNonPositiveDimensions() + { + var canvas = new VectorCanvas(); + + Assert.Throws(() => canvas.FillPie(0, 0, 0, 10, 0, 90)); + Assert.Throws(() => canvas.StrokePie(0, 0, 10, -1, 0, 90)); + } + + [Theory] + [InlineData(15, 120)] + [InlineData(210, 120)] + [InlineData(180, -270)] + public void SectorIsOneSubpathClosedThroughItsCenter(double startAngle, double sweepAngle) + { + string content = PdfTestUtils.InflatedText( + Render(canvas => canvas.FillPie(10, 20, 40, 30, startAngle, sweepAngle))); + + Assert.Single(System.Text.RegularExpressions.Regex.Matches(content, @"(?m)^[-\d.]+ [-\d.]+ m$").Cast()); + Assert.Matches(@" c\n30\.00 [\d.]+ l\nh\nf\n", content); + } +} diff --git a/tests/TerraPDF.Tests/CanvasTextRotationTests.cs b/tests/TerraPDF.Tests/CanvasTextRotationTests.cs new file mode 100644 index 0000000..e003557 --- /dev/null +++ b/tests/TerraPDF.Tests/CanvasTextRotationTests.cs @@ -0,0 +1,55 @@ +using TerraPDF.Core; +using TerraPDF.Helpers; +using Xunit; + +namespace TerraPDF.Tests; + +public sealed class CanvasTextRotationTests +{ + private static readonly string LatoRegularPath = + Path.Combine(AppContext.BaseDirectory, "TestAssets", "Fonts", "Lato-Regular.ttf"); + + private static byte[] Render(Action draw) => + Document.Create(document => document.Page(page => + { + page.Size(PageSize.A4); + page.Margin(0); + page.Content().Canvas(100, draw); + })).PublishPdf(); + + [Fact] + public void NinetyDegreeTextEmitsClockwiseTextMatrix() + { + string content = PdfTestUtils.InflatedText( + Render(canvas => canvas.Text("Turn", 20, 30, angle: 90))); + + Assert.Contains("0.00 -1.00 1.00 0.00 20.00", content); + Assert.Contains(" Tm\n", content); + Assert.Contains("(Turn) Tj\n", content); + } + + [Fact] + public void RotatedCustomFontUsesIdentityHEncoding() + { + string family = $"Rotated-{Guid.NewGuid():N}"; + FontFamily.Register(family, LatoRegularPath); + + string content = PdfTestUtils.InflatedText( + Render(canvas => canvas.Text("Café", 10, 20, fontFamily: family, angle: 37))); + + Assert.Contains("/Encoding /Identity-H", content); + Assert.Contains(" Tm\n", content); + Assert.Matches(@"<[0-9A-F]+> Tj", content); + } + + [Fact] + public void RotatedTextCanShareOpacityGraphicsState() + { + string content = PdfTestUtils.InflatedText( + Render(canvas => canvas.Text("Ghost", 10, 20, angle: -25, opacity: 0.4))); + + Assert.Contains("/Type /ExtGState /ca 0.4000 /CA 0.4000", content); + Assert.Contains("Tm\n", content); + Assert.Contains("Q\n", content); + } +} diff --git a/tests/TerraPDF.Tests/ImageFeatureTests.cs b/tests/TerraPDF.Tests/ImageFeatureTests.cs index 48382bd..9db0766 100644 --- a/tests/TerraPDF.Tests/ImageFeatureTests.cs +++ b/tests/TerraPDF.Tests/ImageFeatureTests.cs @@ -1,4 +1,3 @@ -using System.IO.Compression; using System.Text.RegularExpressions; using TerraPDF.Core; using TerraPDF.Helpers; @@ -25,72 +24,12 @@ private static int CountOccurrences(string text, string token) return count; } - // ── Minimal in-memory PNG builder (CRCs are zeroed; the decoder skips them) ── - - private static byte[] MakePng(int width, int height, bool rgba, byte alphaValue) - { - using var ms = new MemoryStream(); - void WriteBE(int v) { ms.WriteByte((byte)(v >> 24)); ms.WriteByte((byte)(v >> 16)); ms.WriteByte((byte)(v >> 8)); ms.WriteByte((byte)v); } - void Chunk(string type, byte[] data) - { - WriteBE(data.Length); - ms.Write(System.Text.Encoding.ASCII.GetBytes(type)); - ms.Write(data); - WriteBE(0); // CRC — not verified by PngDecoder - } - - ms.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); - - var ihdr = new byte[13]; - ihdr[0] = (byte)(width >> 24); ihdr[1] = (byte)(width >> 16); ihdr[2] = (byte)(width >> 8); ihdr[3] = (byte)width; - ihdr[4] = (byte)(height >> 24); ihdr[5] = (byte)(height >> 16); ihdr[6] = (byte)(height >> 8); ihdr[7] = (byte)height; - ihdr[8] = 8; // bit depth - ihdr[9] = (byte)(rgba ? 6 : 2); // colour type - ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; - Chunk("IHDR", ihdr); - - int bpp = rgba ? 4 : 3; - var scanlines = new byte[height * (1 + width * bpp)]; - int p = 0; - for (int y = 0; y < height; y++) - { - scanlines[p++] = 0; // filter: None - for (int x = 0; x < width; x++) - { - scanlines[p++] = 200; scanlines[p++] = 100; scanlines[p++] = 50; - if (rgba) scanlines[p++] = alphaValue; - } - } - using var idat = new MemoryStream(); - using (var z = new ZLibStream(idat, CompressionLevel.Fastest, leaveOpen: true)) - z.Write(scanlines); - Chunk("IDAT", idat.ToArray()); - Chunk("IEND", []); - return ms.ToArray(); - } - - // Minimal JPEG: SOI + SOF0 (with dims) + EOI — enough for JpegInfo. - private static byte[] MakeJpegHeaderOnly(int width, int height) - { - return - [ - 0xFF, 0xD8, // SOI - 0xFF, 0xC0, 0x00, 0x11, // SOF0, length 17 - 0x08, // precision - (byte)(height >> 8), (byte)height, - (byte)(width >> 8), (byte)width, - 0x03, // 3 components - 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, - 0xFF, 0xD9, // EOI - ]; - } - // ── byte[] / Stream sources ────────────────────────────────────────────── [Fact] public void PngFromBytesProducesImageXObject() { - byte[] png = MakePng(4, 4, rgba: false, alphaValue: 0); + byte[] png = TestImageData.MakePng(4, 4, rgba: false, alphaValue: 0); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -104,7 +43,7 @@ public void PngFromBytesProducesImageXObject() [Fact] public void PngFromStreamProducesImageXObject() { - using var stream = new MemoryStream(MakePng(3, 5, rgba: false, alphaValue: 0)); + using var stream = new MemoryStream(TestImageData.MakePng(3, 5, rgba: false, alphaValue: 0)); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -117,7 +56,7 @@ public void PngFromStreamProducesImageXObject() [Fact] public void JpegFromBytesDetectedByMagicBytes() { - byte[] jpeg = MakeJpegHeaderOnly(7, 9); + byte[] jpeg = TestImageData.MakeJpegHeaderOnly(7, 9); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -145,7 +84,7 @@ public void UnknownImageDataThrowsNotSupported() [Fact] public void TransparentRgbaPngEmitsSMask() { - byte[] png = MakePng(4, 4, rgba: true, alphaValue: 128); + byte[] png = TestImageData.MakePng(4, 4, rgba: true, alphaValue: 128); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -160,7 +99,7 @@ public void TransparentRgbaPngEmitsSMask() [Fact] public void FullyOpaqueRgbaPngEmitsNoSMask() { - byte[] png = MakePng(4, 4, rgba: true, alphaValue: 255); + byte[] png = TestImageData.MakePng(4, 4, rgba: true, alphaValue: 255); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -175,7 +114,7 @@ public void FullyOpaqueRgbaPngEmitsNoSMask() [Fact] public void SameImageOnTwoPagesIsEmbeddedOnce() { - byte[] png = MakePng(6, 6, rgba: false, alphaValue: 0); + byte[] png = TestImageData.MakePng(6, 6, rgba: false, alphaValue: 0); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -195,8 +134,8 @@ public void SameImageOnTwoPagesIsEmbeddedOnce() [Fact] public void DistinctImagesAreEmbeddedSeparately() { - byte[] a = MakePng(6, 6, rgba: false, alphaValue: 0); - byte[] b = MakePng(8, 8, rgba: false, alphaValue: 0); + byte[] a = TestImageData.MakePng(6, 6, rgba: false, alphaValue: 0); + byte[] b = TestImageData.MakePng(8, 8, rgba: false, alphaValue: 0); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); @@ -217,7 +156,7 @@ public void HeightConstrainedImageKeepsAspectRatio() { // 10×1000 px (1:100): at content width the scaled height would far // exceed the page, so both axes must shrink together. - byte[] png = MakePng(10, 1000, rgba: false, alphaValue: 0); + byte[] png = TestImageData.MakePng(10, 1000, rgba: false, alphaValue: 0); byte[] pdf = Build(c => c.Page(p => { p.Size(PageSize.A4); diff --git a/tests/TerraPDF.Tests/TestImageData.cs b/tests/TerraPDF.Tests/TestImageData.cs new file mode 100644 index 0000000..0196bed --- /dev/null +++ b/tests/TerraPDF.Tests/TestImageData.cs @@ -0,0 +1,78 @@ +using System.IO.Compression; +using System.Text; + +namespace TerraPDF.Tests; + +internal static class TestImageData +{ + internal static byte[] MakeJpegHeaderOnly(int width, int height) => + [ + 0xFF, 0xD8, + 0xFF, 0xC0, 0x00, 0x11, + 0x08, + (byte)(height >> 8), (byte)height, + (byte)(width >> 8), (byte)width, + 0x03, + 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, + 0xFF, 0xD9, + ]; + + internal static byte[] MakePng(int width, int height, bool rgba, byte alphaValue) + { + using var stream = new MemoryStream(); + + void WriteBigEndian(int value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + void WriteChunk(string type, byte[] data) + { + WriteBigEndian(data.Length); + stream.Write(Encoding.ASCII.GetBytes(type)); + stream.Write(data); + WriteBigEndian(0); // The test decoder deliberately ignores CRC values. + } + + stream.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + var header = new byte[13]; + header[0] = (byte)(width >> 24); + header[1] = (byte)(width >> 16); + header[2] = (byte)(width >> 8); + header[3] = (byte)width; + header[4] = (byte)(height >> 24); + header[5] = (byte)(height >> 16); + header[6] = (byte)(height >> 8); + header[7] = (byte)height; + header[8] = 8; + header[9] = (byte)(rgba ? 6 : 2); + WriteChunk("IHDR", header); + + int bytesPerPixel = rgba ? 4 : 3; + var scanlines = new byte[height * (1 + width * bytesPerPixel)]; + int offset = 0; + for (int y = 0; y < height; y++) + { + scanlines[offset++] = 0; + for (int x = 0; x < width; x++) + { + scanlines[offset++] = 200; + scanlines[offset++] = 100; + scanlines[offset++] = 50; + if (rgba) + scanlines[offset++] = alphaValue; + } + } + + using var compressed = new MemoryStream(); + using (var zlib = new ZLibStream(compressed, CompressionLevel.Fastest, leaveOpen: true)) + zlib.Write(scanlines); + WriteChunk("IDAT", compressed.ToArray()); + WriteChunk("IEND", []); + return stream.ToArray(); + } +}