diff --git a/README.md b/README.md
index d29dc32..9b0538f 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ How the project is developed and shipped is documented separately:
- Undo and redo for strokes, erasing, containers, text edits, and transformations
- Versioned ZIP-based `.wboard` documents with an embedded `preview.png`. Explorer shows that picture as the file thumbnail in the released install. The VS Code extension in `vscode/sqlbi-whiteboard` opens the same picture instead of the ZIP.
- Markdown `.wimport` recipes that build image and text containers from headings
-- **File → Export** to PowerPoint or PDF: the board is cut into areas where it is empty, one slide or page per area, with an overview first. A deck carries the text containers in the speaker notes, or, as Editable slides, puts images and text containers on the slide as objects with the ink over them; a PDF has a bookmark per page and can put the whole board on one page to zoom into. The dialog shows the areas numbered on the board and updates as the settings change. [docs/export.md](docs/export.md) explains how areas are chosen
+- **File → Export** to PowerPoint or PDF: the board is cut into areas where it is empty, one slide or page per area, with an overview first. A deck carries the text containers in the speaker notes, or, as Editable slides, puts images and text containers on the slide as objects with the ink over them; a PDF has a bookmark per page, can put the whole board on one page to zoom into, and as Vector pages carries the ink as paths and the text as selectable text. The dialog shows the areas numbered on the board and updates as the settings change. [docs/export.md](docs/export.md) explains how areas are chosen
- An intentionally small floating toolbar
- A File / Edit / View / Help tab strip. Click a tab for a one-row command strip over the canvas
- Preferences for the startup monitor, full-screen start, finger drawing, mouse drawing, the pen button, snippet format order, laser trail timing and weight, toolbar position and layout, keeping the Eraser on the toolbar for a pen that has no reverse end, and (except Store installs) a daily new-version check
diff --git a/docs/decisions.md b/docs/decisions.md
index e5c853b..f55ea9c 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -591,13 +591,16 @@ a long-lived MIT library, both managed-only, so they pass the tests decision 21
dependency. The writers live in a new assembly, `SQLBI.Whiteboard.Export`, with no WPF
dependency, which the signing step lists.
-A PDF page is also a picture, for now. PDF has one freedom a slide lacks, a page of any
+A PDF page is a picture by default. PDF has one freedom a slide lacks, a page of any
size, and the export uses it: the whole board can go on one page the shape of the board,
rendered at up to six thousand pixels on the longer edge, for a reader who zooms. Pages get
-a bookmark each and a footer with the board name, date, and page number. Vector pages,
-with selectable text and ink as paths, are a later phase, and so is Print through
-Microsoft Print to PDF, which was weighed as the zero-dependency route and kept for later
-because the driver, not the application, asks for the file name.
+a bookmark each and a footer with the board name, date, and page number. **Vector** is the
+other page content: ink as paths that sweep the nib WPF uses along each stroke, text as
+text in embedded subsets of Segoe UI and Consolas, images as images, so the page stays
+sharp at any zoom and the code can be selected. It is the same element list the editable
+deck uses, with the ink as strokes in their own z-order rather than one overlay. Print
+through Microsoft Print to PDF was weighed as the zero-dependency route and kept for
+later, because the driver, not the application, asks for the file name.
---
diff --git a/docs/export-decisions.md b/docs/export-decisions.md
index 0406ef4..236ae36 100644
--- a/docs/export-decisions.md
+++ b/docs/export-decisions.md
@@ -94,3 +94,32 @@ which phase made it and why.
exported as a placeholder.
- **The picture is still rendered for an editable page** and kept as the page's own
picture in the model, so a page can fall back to it; PowerPoint does not receive it.
+
+## E4 — Vector PDF
+
+- **Page content is a choice on the dialog, Picture or Vector**, for PDF only, with
+ Picture the default for the same reason as the deck: exact beats best effort until
+ someone needs to select text.
+- **The vector page reuses the editable slide's elements**, with one difference: the ink
+ goes out as strokes in their own z-order between the containers, not as one overlay,
+ because a path costs nothing to layer correctly.
+- **A stroke is the sweep of its nib.** For each pair of points the writer fills the
+ convex hull of the two nib polygons, which is exactly the area a convex nib sweeps
+ along a segment, and joins every hull into one path filled once with the non-zero rule,
+ so a translucent stroke does not darken where its own segments overlap. The nibs follow
+ WPF: a pen is an ellipse of diameter thickness × 2 × pressure (pressure 0.5 is the
+ nominal width, which is also why a mouse draws at an even width), a highlighter a flat
+ rectangle four thicknesses wide and two tall with no pressure, calligraphy a tall bar
+ three thicknesses high and 0.65 wide following pressure.
+- **An opaque highlighter is drawn at half opacity**, which is how WPF renders an ink
+ highlighter whose color has no alpha; the board's highlighter swatches are opaque.
+- **Text is laid out line by line in the writer**: paragraphs on line breaks, runs
+ measured left to right, wrapped at the last space that fits and then at a character, in
+ the language's font at the size the board used. It approximates WPF's layout rather than
+ reproducing it, so a line may wrap a character earlier or later than on screen.
+- **Fonts are embedded as subsets** from the Windows fonts folder: Segoe UI and Consolas,
+ with Arial and Courier New standing in when they are missing. Segoe UI and Consolas
+ both allow editable embedding, which covers a subset in a document. Any other family
+ a future language service names falls back to Segoe UI until it is added to the
+ resolver.
+- **The overview page stays a picture** in both modes; it is a map, not content.
diff --git a/site/guide.html b/site/guide.html
index d56aec9..7640485 100644
--- a/site/guide.html
+++ b/site/guide.html
@@ -765,7 +765,7 @@
Files
The File row holds five commands: New, Open, Save, Save As, and Export. Ctrl+S and Ctrl+O save and open a .wboard without opening the row at all.
Export (Ctrl+E) writes the board as a PowerPoint deck. The board is cut into areas wherever it is empty: a picture or a note keeps the ink drawn on it, and a stroke that spans two of them keeps them together. Each area becomes a slide, with the text containers in the speaker notes so that DAX and SQL can be copied, and an overview slide first shows where every slide sits on the board. The dialog previews the areas, numbered, and the Gap between areas slider and Smallest text on a slide change the cut as you move them. Drawing order puts slides in the order the areas were begun; Reading order goes top-left to bottom-right.
Slide content chooses between Picture, one exact picture of the area, and Editable, which puts images and text containers on the slide as PowerPoint objects, keeps the DAX and SQL colors as text runs, and lays all the ink over them as one transparent picture. Editable is for a deck that will be reworked; Picture is for one that will be shown.
-
Choose PDF in the same dialog for a document instead: the same areas, one per A4 or Letter page in landscape, each with a bookmark, and a footer with the board name, the date, and the page number. Whole board on one page writes a single page the shape of the board, to be read by zooming, which suits a board that is one drawing.
+
Choose PDF in the same dialog for a document instead: the same areas, one per A4 or Letter page in landscape, each with a bookmark, and a footer with the board name, the date, and the page number. Whole board on one page writes a single page the shape of the board, to be read by zooming, which suits a board that is one drawing. Page content chooses between Picture, exact, and Vector, which draws the ink as paths and the text as text, so the page stays sharp at any zoom and DAX or SQL can be selected and copied from it.
The released installer registers that type and .wimport. The pre-release (Dev) channel does not, so it can sit beside a released copy without stealing the file association.
diff --git a/src/SQLBI.Whiteboard.Core/Settings/ExportSettings.cs b/src/SQLBI.Whiteboard.Core/Settings/ExportSettings.cs
index 41bef05..475021c 100644
--- a/src/SQLBI.Whiteboard.Core/Settings/ExportSettings.cs
+++ b/src/SQLBI.Whiteboard.Core/Settings/ExportSettings.cs
@@ -33,6 +33,20 @@ public enum ExportSlideAspect
Standard = 1,
}
+public enum ExportPageContent
+{
+ ///
+ /// One picture of the area per page, exactly as the screen draws it.
+ ///
+ Picture = 0,
+
+ ///
+ /// Ink as paths, text as text, images as images: selectable, and sharp
+ /// at any zoom.
+ ///
+ Vector = 1,
+}
+
public enum ExportSlideContent
{
///
@@ -75,6 +89,8 @@ public sealed class ExportSettings
public ExportPageSize PageSize { get; set; } = ExportPageSize.A4;
+ public ExportPageContent PageContent { get; set; } = ExportPageContent.Picture;
+
///
/// The board name, the date, and "n of m" at the foot of every PDF page.
///
@@ -118,6 +134,11 @@ public static ExportSettings Normalize(ExportSettings? settings)
result.SlideContent = ExportSlideContent.Picture;
}
+ if (!Enum.IsDefined(result.PageContent))
+ {
+ result.PageContent = ExportPageContent.Picture;
+ }
+
result.GapThreshold = double.IsFinite(result.GapThreshold)
? Math.Clamp(
result.GapThreshold,
diff --git a/src/SQLBI.Whiteboard.Export/ExportPage.cs b/src/SQLBI.Whiteboard.Export/ExportPage.cs
index 3f4f894..1aabfea 100644
--- a/src/SQLBI.Whiteboard.Export/ExportPage.cs
+++ b/src/SQLBI.Whiteboard.Export/ExportPage.cs
@@ -47,6 +47,37 @@ public sealed record SlideTextElement(
uint BorderArgb,
uint TextArgb) : SlideElement(Bounds);
+public enum SlideStrokeKind
+{
+ /// An ellipse nib whose diameter follows pressure.
+ Pen = 0,
+
+ /// A flat rectangle nib four times wider than tall, with no pressure.
+ Highlighter = 1,
+
+ /// A tall rectangle nib, three times taller than the thickness and 0.65 of it wide, following pressure.
+ Calligraphy = 2,
+}
+
+public readonly record struct SlidePoint(double X, double Y, float Pressure);
+
+///
+/// One stroke in page pixels. Thickness is the nominal width, in page pixels, at
+/// the pressure WPF treats as normal (0.5): the nib is Thickness × 2 × pressure across
+/// for the kinds that follow pressure. Argb keeps its alpha; a highlighter is translucent.
+///
+public sealed record SlideStroke(
+ IReadOnlyList Points,
+ uint Argb,
+ double Thickness,
+ SlideStrokeKind Kind);
+
+///
+/// Ink as vector strokes, for a writer that can draw them. The PowerPoint writer does
+/// not: it takes the ink as an image element instead.
+///
+public sealed record SlideInkElement(SlideRect Bounds, IReadOnlyList Strokes) : SlideElement(Bounds);
+
/// 16:9 or 4:3.
public enum SlideAspect
{
diff --git a/src/SQLBI.Whiteboard.Export/PdfDocumentWriter.cs b/src/SQLBI.Whiteboard.Export/PdfDocumentWriter.cs
index 239f29b..980e3a9 100644
--- a/src/SQLBI.Whiteboard.Export/PdfDocumentWriter.cs
+++ b/src/SQLBI.Whiteboard.Export/PdfDocumentWriter.cs
@@ -25,9 +25,11 @@ public sealed record PdfOptions(
bool FitPageToPicture = false);
///
-/// Writes one picture per page as a PDF, with a bookmark per page so that a reader's
-/// outline panel lists the areas. Text is set in Segoe UI read from the Windows fonts
-/// folder, because PDFsharp's Core build brings no fonts of its own.
+/// Writes one page per slide as a PDF, with a bookmark per page so that a reader's
+/// outline panel lists the areas. A page with elements is drawn as vector content in the
+/// rectangle its picture would have filled; any other page takes the picture. Text is
+/// set in fonts read from the Windows fonts folder, because PDFsharp's Core build brings
+/// no fonts of its own.
///
public static class PdfDocumentWriter
{
@@ -43,7 +45,16 @@ public static class PdfDocumentWriter
private const double PixelsPerPoint = 2;
private const double MaxPageSide = 14400;
+ // Element geometry. Pixel values go through the page's scale; the border is in points.
+ private const double TextBoxCornerRadius = 4;
+ private const double TextBoxBorderWidth = 0.75;
+ private const double TitleGap = 0.4;
+ private const double MinNibSize = 0.4;
+ private const int PenNibSides = 16;
+ private const int HighlighterAlpha = 128;
+
private const string FontFamily = "Segoe UI";
+ private const string MonospaceFontFamily = "Consolas";
private const string Ellipsis = "…";
private const string Application = "SQLBI Whiteboard";
private const string DefaultTitle = "Board";
@@ -135,7 +146,7 @@ private static PdfPage AddFixedPage(
bottom = footer.Top - HeaderGap;
}
- DrawPicture(graphics, page, new XRect(Margin, top, contentWidth, bottom - top), centerVertically: false);
+ DrawPage(graphics, page, new XRect(Margin, top, contentWidth, bottom - top), centerVertically: false);
return pdfPage;
}
@@ -150,11 +161,13 @@ private static PdfPage AddFittedPage(PdfDocument document, ExportPage page)
// A picture only a few pixels across would leave nothing inside the margin.
var margin = Math.Min(FitMargin, Math.Min(size.Width, size.Height) / 4);
var box = new XRect(margin, margin, size.Width - 2 * margin, size.Height - 2 * margin);
- DrawPicture(graphics, page, box, centerVertically: true);
+ DrawPage(graphics, page, box, centerVertically: true);
return pdfPage;
}
- private static void DrawPicture(XGraphics graphics, ExportPage page, XRect box, bool centerVertically)
+ // The page's pixels are scaled uniformly into the box and centred across it; the
+ // single picture and every element go through the same mapping.
+ private static void DrawPage(XGraphics graphics, ExportPage page, XRect box, bool centerVertically)
{
var scale = Math.Min(box.Width / page.PixelWidth, box.Height / page.PixelHeight);
var width = page.PixelWidth * scale;
@@ -162,11 +175,314 @@ private static void DrawPicture(XGraphics graphics, ExportPage page, XRect box,
var x = box.X + (box.Width - width) / 2;
var y = centerVertically ? box.Y + (box.Height - height) / 2 : box.Y;
- using var png = new MemoryStream(page.Png, writable: false);
- using var image = XImage.FromStream(png);
- graphics.DrawImage(image, x, y, width, height);
+ if (page.Elements is null)
+ {
+ DrawImage(graphics, page.Png, new XRect(x, y, width, height));
+ return;
+ }
+
+ // Elements are listed back to front, which is also the drawing order.
+ var mapping = new PixelMapping(scale, x, y);
+ foreach (var element in page.Elements)
+ {
+ switch (element)
+ {
+ case SlideImageElement image:
+ DrawImage(graphics, image.Data, mapping.Map(image.Bounds));
+ break;
+ case SlideTextElement text:
+ DrawTextBox(graphics, text, mapping);
+ break;
+ case SlideInkElement ink:
+ DrawInk(graphics, ink, mapping);
+ break;
+ default:
+ throw new ArgumentException($"Unsupported slide element {element.GetType().Name}.", nameof(page));
+ }
+ }
+ }
+
+ private static void DrawImage(XGraphics graphics, byte[] data, XRect rect)
+ {
+ using var stream = new MemoryStream(data, writable: false);
+ using var image = XImage.FromStream(stream);
+ graphics.DrawImage(image, rect);
+ }
+
+ ///
+ /// A text container as a text box: the title line, a gap, then the body runs laid out
+ /// left to right and wrapped at the right inset. Everything is clipped to the box, as
+ /// the board clips it, so text below the bottom is simply not seen.
+ ///
+ private static void DrawTextBox(XGraphics graphics, SlideTextElement text, PixelMapping mapping)
+ {
+ var rect = mapping.Map(text.Bounds);
+ var corner = 2 * mapping.Map(TextBoxCornerRadius);
+ graphics.DrawRoundedRectangle(
+ new XPen(Color(text.BorderArgb), TextBoxBorderWidth),
+ new XSolidBrush(Color(text.BackgroundArgb)),
+ rect.X,
+ rect.Y,
+ rect.Width,
+ rect.Height,
+ corner,
+ corner);
+
+ var padding = mapping.Map(text.Padding);
+ var left = rect.X + padding;
+ var right = rect.Right - padding;
+ var bottom = rect.Bottom - padding;
+ var y = rect.Y + padding;
+ if (right <= left || bottom <= y)
+ {
+ return;
+ }
+
+ var state = graphics.Save();
+ graphics.IntersectClip(rect);
+
+ var titleFont = new XFont(FontFamily, mapping.Map(text.TitleFontSize), XFontStyleEx.Bold);
+ var bodySize = mapping.Map(text.BodyFontSize);
+ var fonts = new Dictionary();
+ XFont BodyFont(bool bold, bool italic)
+ {
+ var style = (bold ? XFontStyleEx.Bold : XFontStyleEx.Regular) | (italic ? XFontStyleEx.Italic : XFontStyleEx.Regular);
+ if (!fonts.TryGetValue(style, out var font))
+ {
+ font = new XFont(text.FontFamily, bodySize, style);
+ fonts.Add(style, font);
+ }
+
+ return font;
+ }
+
+ // The title line takes its space even when empty, as the board's does.
+ if (text.Title.Length > 0)
+ {
+ graphics.DrawString(text.Title, titleFont, new XSolidBrush(Color(text.TextArgb, opaque: true)), new XPoint(left, y), XStringFormats.TopLeft);
+ }
+
+ y += titleFont.GetHeight() + TitleGap * bodySize;
+ var lineHeight = BodyFont(bold: false, italic: false).GetHeight();
+
+ foreach (var paragraph in Paragraphs(text.Runs))
+ {
+ if (y >= bottom)
+ {
+ break;
+ }
+
+ var x = left;
+ foreach (var (fragment, run) in paragraph)
+ {
+ var font = BodyFont(run.Bold, run.Italic);
+ var brush = new XSolidBrush(Color(run.Argb, opaque: true));
+ var rest = fragment;
+ while (rest.Length > 0 && y < bottom)
+ {
+ var fit = FitLength(graphics, font, rest, right - x);
+ if (fit == rest.Length)
+ {
+ graphics.DrawString(rest, font, brush, new XPoint(x, y), XStringFormats.TopLeft);
+ x += graphics.MeasureString(rest, font).Width;
+ break;
+ }
+
+ // Break at the last space that fits. A fragment without one moves to
+ // the next line whole when something precedes it on the line, and is
+ // cut at the last character that fits when it starts the line.
+ var space = rest.LastIndexOf(' ', Math.Max(0, fit - 1));
+ var head = space > 0 ? space : x > left ? 0 : Math.Max(1, fit);
+ if (head > 0)
+ {
+ graphics.DrawString(rest[..head], font, brush, new XPoint(x, y), XStringFormats.TopLeft);
+ }
+
+ rest = rest[(space > 0 ? space + 1 : head)..];
+ x = left;
+ y += lineHeight;
+ }
+ }
+
+ y += lineHeight;
+ }
+
+ graphics.Restore(state);
+ }
+
+ // The runs' text cut into paragraphs at line breaks, each paragraph as the fragments
+ // of the runs it spans. A paragraph without fragments is an empty line.
+ private static List> Paragraphs(IReadOnlyList runs)
+ {
+ var paragraphs = new List> { new() };
+ foreach (var run in runs)
+ {
+ var lines = run.Text.Split('\n');
+ for (var index = 0; index < lines.Length; index++)
+ {
+ if (index > 0)
+ {
+ paragraphs.Add(new());
+ }
+
+ var fragment = lines[index].TrimEnd('\r');
+ if (fragment.Length > 0)
+ {
+ paragraphs[^1].Add((fragment, run));
+ }
+ }
+ }
+
+ return paragraphs;
+ }
+
+ // The longest prefix of the text that fits in the width, in characters; widths grow
+ // with the length, so a binary search finds it.
+ private static int FitLength(XGraphics graphics, XFont font, string text, double width)
+ {
+ if (graphics.MeasureString(text, font).Width <= width)
+ {
+ return text.Length;
+ }
+
+ var low = 0;
+ var high = text.Length - 1;
+ while (low < high)
+ {
+ var middle = (low + high + 1) / 2;
+ if (graphics.MeasureString(text[..middle], font).Width <= width)
+ {
+ low = middle;
+ }
+ else
+ {
+ high = middle - 1;
+ }
+ }
+
+ return low;
+ }
+
+ ///
+ /// Every stroke is one filled path: the sweep of its nib along its points, each segment
+ /// as the convex hull of the nib at both ends. One fill with the winding rule keeps the
+ /// overlaps between segments from darkening a translucent stroke.
+ ///
+ private static void DrawInk(XGraphics graphics, SlideInkElement ink, PixelMapping mapping)
+ {
+ foreach (var stroke in ink.Strokes)
+ {
+ if (stroke.Points.Count == 0)
+ {
+ continue;
+ }
+
+ var path = new XGraphicsPath { FillMode = XFillMode.Winding };
+ var segments = 0;
+ for (var index = 0; index + 1 < stroke.Points.Count; index++)
+ {
+ var from = stroke.Points[index];
+ var to = stroke.Points[index + 1];
+ if (from.X == to.X && from.Y == to.Y)
+ {
+ continue;
+ }
+
+ path.AddPolygon(mapping.Map(ConvexHull([.. Nib(stroke, from), .. Nib(stroke, to)])));
+ segments++;
+ }
+
+ if (segments == 0)
+ {
+ path.AddPolygon(mapping.Map(Nib(stroke, stroke.Points[0])));
+ }
+
+ // WPF renders an opaque highlighter at half opacity.
+ var argb = stroke.Argb;
+ if (stroke.Kind == SlideStrokeKind.Highlighter && argb >> 24 == 0xFF)
+ {
+ argb = (uint)HighlighterAlpha << 24 | argb & 0xFFFFFF;
+ }
+
+ graphics.DrawPath(new XSolidBrush(Color(argb)), path);
+ }
}
+ // The nib as a polygon around a point, in page pixels. The pressure-driven size is
+ // Thickness at the pressure WPF treats as normal (0.5), and never collapses to nothing.
+ private static XPoint[] Nib(SlideStroke stroke, SlidePoint point)
+ {
+ var pressed = Math.Max(MinNibSize, stroke.Thickness * 2 * point.Pressure);
+ switch (stroke.Kind)
+ {
+ case SlideStrokeKind.Highlighter:
+ return Rectangle(point, 4 * stroke.Thickness, 2 * stroke.Thickness);
+ case SlideStrokeKind.Calligraphy:
+ return Rectangle(point, 0.65 * pressed, 3 * pressed);
+ default:
+ var radius = pressed / 2;
+ var polygon = new XPoint[PenNibSides];
+ for (var index = 0; index < PenNibSides; index++)
+ {
+ var angle = 2 * Math.PI * index / PenNibSides;
+ polygon[index] = new XPoint(point.X + radius * Math.Cos(angle), point.Y + radius * Math.Sin(angle));
+ }
+
+ return polygon;
+ }
+
+ static XPoint[] Rectangle(SlidePoint center, double width, double height) =>
+ [
+ new(center.X - width / 2, center.Y - height / 2),
+ new(center.X + width / 2, center.Y - height / 2),
+ new(center.X + width / 2, center.Y + height / 2),
+ new(center.X - width / 2, center.Y + height / 2),
+ ];
+ }
+
+ // Andrew's monotone chain. The hull of the nib at both ends of a segment is the area
+ // the nib sweeps between them, since a nib is convex.
+ private static XPoint[] ConvexHull(XPoint[] points)
+ {
+ Array.Sort(points, (a, b) => a.X != b.X ? a.X.CompareTo(b.X) : a.Y.CompareTo(b.Y));
+ var hull = new List(points.Length + 1);
+ foreach (var point in points)
+ {
+ AddTurningLeft(hull, point, keep: 1);
+ }
+
+ // The upper chain must not pop its way back into the lower one.
+ var lowerCount = hull.Count;
+ for (var index = points.Length - 2; index >= 0; index--)
+ {
+ AddTurningLeft(hull, points[index], keep: lowerCount);
+ }
+
+ // The chain ends where it started.
+ hull.RemoveAt(hull.Count - 1);
+ return [.. hull];
+
+ static void AddTurningLeft(List hull, XPoint point, int keep)
+ {
+ while (hull.Count > keep && Cross(hull[^2], hull[^1], point) <= 0)
+ {
+ hull.RemoveAt(hull.Count - 1);
+ }
+
+ hull.Add(point);
+ }
+
+ static double Cross(XPoint o, XPoint a, XPoint b) =>
+ (a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X);
+ }
+
+ // Text carries no alpha on the board, so the color's own alpha is dropped for it.
+ private static XColor Color(uint argb, bool opaque = false) => XColor.FromArgb(
+ opaque ? 0xFF : (int)(argb >> 24),
+ (int)(argb >> 16 & 0xFF),
+ (int)(argb >> 8 & 0xFF),
+ (int)(argb & 0xFF));
+
// The header is one line: a title wider than the page is cut and ended with an
// ellipsis rather than wrapped, so that the picture keeps its space.
private static string FitOnOneLine(XGraphics graphics, XFont font, string text, double width)
@@ -219,17 +535,40 @@ private static void SetSize(PdfPage page, XSize size)
page.Height = XUnit.FromPoint(size.Height);
}
+ ///
+ /// Where a page's pixels land on the PDF page: Scale is points per pixel, and the
+ /// origin is where pixel (0,0) falls.
+ ///
+ private readonly record struct PixelMapping(double Scale, double OriginX, double OriginY)
+ {
+ public double Map(double length) => length * Scale;
+
+ public XPoint Map(XPoint point) => new(OriginX + point.X * Scale, OriginY + point.Y * Scale);
+
+ public XPoint[] Map(XPoint[] points)
+ {
+ var mapped = new XPoint[points.Length];
+ for (var index = 0; index < points.Length; index++)
+ {
+ mapped[index] = Map(points[index]);
+ }
+
+ return mapped;
+ }
+
+ public XRect Map(SlideRect rect) => new(OriginX + rect.X * Scale, OriginY + rect.Y * Scale, rect.Width * Scale, rect.Height * Scale);
+ }
+
///
/// PDFsharp's Core build resolves no fonts by itself, and the Windows resolver it
- /// offers knows Arial but not Segoe UI, so the two faces are read from the Windows
- /// fonts folder here. PDFsharp accepts one resolver per process, so a single instance
- /// is installed once and shared by every Write.
+ /// offers knows Arial but not Segoe UI, so the faces are read from the Windows fonts
+ /// folder here: Segoe UI for the page furniture and the text boxes, Consolas for the
+ /// monospace ones, and Segoe UI again for any other family a board names. PDFsharp
+ /// accepts one resolver per process, so a single instance is installed once and
+ /// shared by every Write.
///
private sealed class WindowsFontResolver : IFontResolver
{
- private const string RegularFace = "SegoeUI";
- private const string BoldFace = "SegoeUI#b";
-
private static readonly Lazy Installed = new(() =>
{
var resolver = new WindowsFontResolver();
@@ -237,29 +576,55 @@ private sealed class WindowsFontResolver : IFontResolver
return resolver;
});
- // Arial stands in on a Windows without Segoe UI, such as a Server Core.
- private static readonly Lazy Regular = new(() => ReadFont("segoeui.ttf", "arial.ttf"));
- private static readonly Lazy Bold = new(() => ReadFont("segoeuib.ttf", "arialbd.ttf"));
+ // Arial and Courier New stand in on a Windows without Segoe UI or Consolas, such
+ // as a Server Core. Each family lists regular, bold, italic, and bold italic.
+ private static readonly FontFiles SegoeUI = new(
+ "SegoeUI",
+ ["segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf", "segoeuiz.ttf"],
+ ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]);
- public static void Install() => _ = Installed.Value;
+ private static readonly FontFiles Consolas = new(
+ "Consolas",
+ ["consola.ttf", "consolab.ttf", "consolai.ttf", "consolaz.ttf"],
+ ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]);
+
+ private static readonly Dictionary> Faces = new[] { SegoeUI, Consolas }
+ .SelectMany(family => family.Faces())
+ .ToDictionary(face => face.Name, face => face.Bytes, StringComparer.Ordinal);
- public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic) =>
- string.Equals(familyName, FontFamily, StringComparison.OrdinalIgnoreCase)
- ? new FontResolverInfo(bold ? BoldFace : RegularFace, false, italic)
- : null;
+ public static void Install() => _ = Installed.Value;
- public byte[]? GetFont(string faceName) => faceName switch
+ public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic)
{
- RegularFace => Regular.Value,
- BoldFace => Bold.Value,
- _ => null,
- };
+ var family = string.Equals(familyName, MonospaceFontFamily, StringComparison.OrdinalIgnoreCase) ? Consolas : SegoeUI;
+ return new FontResolverInfo(family.FaceName(bold, italic));
+ }
- private static byte[] ReadFont(string fileName, string fallbackFileName)
+ public byte[]? GetFont(string faceName) =>
+ Faces.TryGetValue(faceName, out var bytes) ? bytes.Value : null;
+
+ private sealed record FontFiles(string Name, string[] FileNames, string[] FallbackFileNames)
{
- var folder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
- var path = Path.Combine(folder, fileName);
- return File.ReadAllBytes(File.Exists(path) ? path : Path.Combine(folder, fallbackFileName));
+ private static readonly string[] StyleSuffixes = ["", "#b", "#i", "#bi"];
+
+ public string FaceName(bool bold, bool italic) => Name + StyleSuffixes[(bold ? 1 : 0) + (italic ? 2 : 0)];
+
+ public IEnumerable<(string Name, Lazy Bytes)> Faces()
+ {
+ for (var index = 0; index < StyleSuffixes.Length; index++)
+ {
+ var fileName = FileNames[index];
+ var fallbackFileName = FallbackFileNames[index];
+ yield return (Name + StyleSuffixes[index], new Lazy(() => ReadFont(fileName, fallbackFileName)));
+ }
+ }
+
+ private static byte[] ReadFont(string fileName, string fallbackFileName)
+ {
+ var folder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
+ var path = Path.Combine(folder, fileName);
+ return File.ReadAllBytes(File.Exists(path) ? path : Path.Combine(folder, fallbackFileName));
+ }
}
}
}
diff --git a/src/SQLBI.Whiteboard/Export/BoardExporter.cs b/src/SQLBI.Whiteboard/Export/BoardExporter.cs
index 7d19962..1263479 100644
--- a/src/SQLBI.Whiteboard/Export/BoardExporter.cs
+++ b/src/SQLBI.Whiteboard/Export/BoardExporter.cs
@@ -158,6 +158,8 @@ private static ExportPage RenderArea(
liveViewImageSourceProvider);
var editable = settings.Format == ExportFormat.PowerPoint &&
settings.SlideContent == ExportSlideContent.Editable;
+ var vector = settings.Format == ExportFormat.Pdf &&
+ settings.PageContent == ExportPageContent.Vector;
return new ExportPage(
PageTitle(area, singlePageTitle),
@@ -165,8 +167,8 @@ private static ExportPage RenderArea(
WpfImageCodec.EncodePng(bitmap),
width,
height,
- editable
- ? EditableSlide.Build(document, area, width, height, liveViewImageSourceProvider)
+ editable || vector
+ ? EditableSlide.Build(document, area, width, height, liveViewImageSourceProvider, inkAsStrokes: vector)
: null);
}
diff --git a/src/SQLBI.Whiteboard/Export/EditableSlide.cs b/src/SQLBI.Whiteboard/Export/EditableSlide.cs
index 3f1362d..2eb9cb3 100644
--- a/src/SQLBI.Whiteboard/Export/EditableSlide.cs
+++ b/src/SQLBI.Whiteboard/Export/EditableSlide.cs
@@ -24,17 +24,40 @@ internal static class EditableSlide
private const uint TextBorderArgb = 0xFFD6D9DE;
private const uint TextArgb = 0xFF1F2937;
+ ///
+ /// With the ink goes out as vector strokes in
+ /// their own z-order between the containers, for a writer that draws paths;
+ /// otherwise as one transparent picture over everything.
+ ///
public static IReadOnlyList Build(
BoardDocument document,
ExportArea area,
int pixelWidth,
int pixelHeight,
- Func? liveViewImageSourceProvider)
+ Func? liveViewImageSourceProvider,
+ bool inkAsStrokes = false)
{
var camera = BoardRasterizer.CameraFor(area.Bounds, pixelWidth, pixelHeight);
var elements = new List();
+ var strokes = new List();
foreach (var item in area.Objects)
{
+ if (item is InkStrokeObject stroke)
+ {
+ if (inkAsStrokes)
+ {
+ strokes.Add(Stroke(stroke, camera));
+ }
+
+ continue;
+ }
+
+ if (strokes.Count > 0)
+ {
+ elements.Add(InkElement(strokes, pixelWidth, pixelHeight));
+ strokes = [];
+ }
+
var element = item switch
{
ImageBoardObject image => ImageElement(document, image, camera),
@@ -48,7 +71,12 @@ public static IReadOnlyList Build(
}
}
- if (area.Objects.OfType().Any())
+ if (strokes.Count > 0)
+ {
+ elements.Add(InkElement(strokes, pixelWidth, pixelHeight));
+ }
+
+ if (!inkAsStrokes && area.Objects.OfType().Any())
{
var ink = BoardRasterizer.Render(
document,
@@ -67,6 +95,24 @@ public static IReadOnlyList Build(
return elements;
}
+ private static SlideInkElement InkElement(List strokes, int pixelWidth, int pixelHeight) =>
+ new(new SlideRect(0, 0, pixelWidth, pixelHeight), strokes);
+
+ private static SlideStroke Stroke(InkStrokeObject stroke, Camera2D camera) => new(
+ stroke.Points.Select(point =>
+ {
+ var screen = camera.WorldToScreen(point.Position);
+ return new SlidePoint(screen.X, screen.Y, Math.Clamp(point.Pressure, 0f, 1f));
+ }).ToArray(),
+ stroke.Style.Argb,
+ stroke.Style.Thickness * camera.Zoom,
+ stroke.Style.Kind switch
+ {
+ PenKind.Highlighter => SlideStrokeKind.Highlighter,
+ PenKind.Calligraphy => SlideStrokeKind.Calligraphy,
+ _ => SlideStrokeKind.Pen,
+ });
+
private static SlideElement? ImageElement(BoardDocument document, ImageBoardObject image, Camera2D camera)
{
if (!document.Assets.TryGetValue(image.AssetId, out var asset))
diff --git a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml
index dec6499..f93653e 100644
--- a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml
+++ b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml
@@ -168,6 +168,18 @@
Margin="0,4,0,0"
Text="Editable puts images and text containers on the slide as objects, with the ink as one transparent picture over them. Picture is exact." />
+
+
+
+
diff --git a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs
index 8756665..45ef5eb 100644
--- a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs
@@ -99,6 +99,10 @@ private void PopulateOptions()
FormatCombo.Items.Add(new Choice("PDF", ExportFormat.Pdf));
Select(FormatCombo, _settings.Format);
+ PageContentCombo.Items.Add(new Choice("Picture", ExportPageContent.Picture));
+ PageContentCombo.Items.Add(new Choice("Vector", ExportPageContent.Vector));
+ Select(PageContentCombo, _settings.PageContent);
+
PageSizeCombo.Items.Add(new Choice("A4, landscape", ExportPageSize.A4));
PageSizeCombo.Items.Add(new Choice("Letter, landscape", ExportPageSize.Letter));
Select(PageSizeCombo, _settings.PageSize);
@@ -177,6 +181,7 @@ private void ReadOptions()
_settings.SlideAspect = Selected(AspectCombo, ExportSlideAspect.Wide);
_settings.SlideContent = Selected(ContentCombo, ExportSlideContent.Picture);
_settings.PageSize = Selected(PageSizeCombo, ExportPageSize.A4);
+ _settings.PageContent = Selected(PageContentCombo, ExportPageContent.Picture);
_settings.GapThreshold = GapSlider.Value;
_settings.IncludeOverview = OverviewSwitch.IsChecked == true;
_settings.IncludeNotes = NotesSwitch.IsChecked == true;
@@ -202,6 +207,9 @@ private void Rebuild()
NotesRow.Visibility = AspectLabel.Visibility;
PageSizeLabel.Visibility = pdf ? Visibility.Visible : Visibility.Collapsed;
PageSizeCombo.Visibility = PageSizeLabel.Visibility;
+ PageContentLabel.Visibility = PageSizeLabel.Visibility;
+ PageContentCombo.Visibility = PageSizeLabel.Visibility;
+ PageContentHint.Visibility = PageSizeLabel.Visibility;
FooterRow.Visibility = PageSizeLabel.Visibility;
var perArea = _settings.PageModel == ExportPageModel.OnePerArea;
diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
index 851a24e..c2b71f7 100644
--- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
@@ -1343,6 +1343,39 @@ [new ExportPage("Editable", null, onePixelPng, 1600, 900, elements)],
Assert(pdf.Outlines.Count == 3, "Every page has a bookmark.");
}
+ // A vector page: the same elements as the editable slide, with the ink as
+ // strokes rather than a picture, drawn as paths and text.
+ SlidePoint[] sine = Enumerable.Range(0, 40)
+ .Select(i => new SlidePoint(100 + (i * 20), 600 + (80 * Math.Sin(i / 4.0)), (float)(0.2 + (0.6 * Math.Abs(Math.Sin(i / 3.0))))))
+ .ToArray();
+ SlideElement[] vectorElements =
+ [
+ elements[0],
+ elements[1],
+ new SlideInkElement(
+ new SlideRect(0, 0, 1600, 900),
+ [
+ new SlideStroke(sine, 0xFFE64B3D, 4, SlideStrokeKind.Pen),
+ new SlideStroke([new SlidePoint(580, 150, 0.5f), new SlidePoint(1120, 160, 0.5f)], 0xFFFACC15, 6, SlideStrokeKind.Highlighter),
+ new SlideStroke([new SlidePoint(200, 300, 0.3f), new SlidePoint(300, 260, 0.8f), new SlidePoint(420, 330, 0.5f)], 0xFF1F2937, 4, SlideStrokeKind.Calligraphy),
+ new SlideStroke([new SlidePoint(50, 50, 0.5f)], 0xFF1F2937, 8, SlideStrokeKind.Pen),
+ ]),
+ ];
+ using var vectorStream = new MemoryStream();
+ PdfDocumentWriter.Write(
+ vectorStream,
+ [new ExportPage("Vector", null, onePixelPng, 1600, 900, vectorElements)],
+ new PdfOptions(BoardName: "Contoso workshop"));
+ var vectorBytes = vectorStream.ToArray();
+ Assert(
+ System.Text.Encoding.Latin1.GetString(vectorBytes).Contains("Consolas", StringComparison.Ordinal),
+ "A vector page embeds the text's font, so the text is text.");
+ vectorStream.Position = 0;
+ using (var vector = PdfSharp.Pdf.IO.PdfReader.Open(vectorStream, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import))
+ {
+ Assert(vector.PageCount == 1, "A vector page is one page.");
+ }
+
using var posterStream = new MemoryStream();
PdfDocumentWriter.Write(posterStream, deckPages[..1], new PdfOptions(FitPageToPicture: true));
posterStream.Position = 0;