diff --git a/README.md b/README.md
index 2cb2011..d29dc32 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; 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 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
- 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 7acf992..e5c853b 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -579,8 +579,12 @@ scaled down and the dialog says by how much, rather than being tiled across slid
The slide is a picture rendered by the same `BoardSurface` that draws the screen, at twice
full HD, so calligraphy, the highlighter, and SVG come out as they are seen. The text
-containers go in the speaker notes so DAX and SQL can still be copied. An editable deck,
-with native text boxes and images, is a later phase.
+containers go in the speaker notes so DAX and SQL can still be copied. **Editable** is the
+other slide content: images as pictures, text containers as text boxes carrying the
+syntax colors the screen shows, and all the ink as one transparent picture on top, placed
+through the same camera the picture would have used. It is best effort where the picture
+is exact, which is why the picture stays the default. Ink as freeform shapes was left
+out: it would be a second stroke renderer to keep in step with the first.
`DocumentFormat.OpenXml` writes the deck and `PdfSharp` the PDF: Microsoft's own SDK and
a long-lived MIT library, both managed-only, so they pass the tests decision 21 set for a
diff --git a/docs/export-decisions.md b/docs/export-decisions.md
index 620d481..0406ef4 100644
--- a/docs/export-decisions.md
+++ b/docs/export-decisions.md
@@ -69,3 +69,28 @@ which phase made it and why.
- **The overview page** works as for the deck: first, only with two or more areas.
- **`PdfSharp` 6.2.4** is the writer, MIT and managed-only. Fonts come from the Windows
fonts folder through its platform resolver; the export runs only on Windows.
+
+## E3 — Editable deck
+
+- **Slide content is a choice on the dialog, Picture or Editable**, shown for PowerPoint
+ only. Picture stays the default: it is exact, and Editable is best effort by design.
+- **Elements are placed in the picture's own pixel space.** The rasterizer exposes the
+ camera it would have used, and every image, text box, and the ink overlay is mapped
+ through it, so switching between the two modes moves nothing.
+- **Bitmaps go in as they are when they are PNG or JPEG**, sniffed from the bytes rather
+ than trusted to the stored content type, because boards written before SVG support have
+ none. SVG, BMP, and GIF are rasterized at the size they take on the slide, through the
+ same code the clipboard uses.
+- **A text container is one rounded text box**: the language service's title as the first
+ paragraph in Segoe UI semibold, then the body in the language's monospace font, with the
+ syntax colors and weights carried over as runs. The text box has PowerPoint autofit
+ switched off, so the text keeps the size it had on the board; a container that has
+ been resized smaller than its text on the board is clipped there, and overflows here.
+- **The ink is one transparent PNG over everything**, rendered by the same surface with
+ the background off and only strokes drawn. It sits above every container, which is
+ wrong only for a stroke drawn before an image was dropped on top of it.
+- **A LiveView goes in as its current frame**, or its saved snapshot when nothing is
+ connected. A missing image (an asset the board no longer has) is left out rather than
+ 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.
diff --git a/site/guide.html b/site/guide.html
index a9e09c5..d56aec9 100644
--- a/site/guide.html
+++ b/site/guide.html
@@ -764,6 +764,7 @@
LiveView
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.
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 aa317cc..41bef05 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 ExportSlideContent
+{
+ ///
+ /// One picture of the area, exactly as the screen draws it.
+ ///
+ Picture = 0,
+
+ ///
+ /// Images and text containers as PowerPoint objects, with the ink as one
+ /// transparent picture over them.
+ ///
+ Editable = 1,
+}
+
///
/// What the export dialog remembers between uses. These are not in Preferences:
/// they belong to the dialog that shows their effect.
@@ -57,6 +71,8 @@ public sealed class ExportSettings
public ExportSlideAspect SlideAspect { get; set; } = ExportSlideAspect.Wide;
+ public ExportSlideContent SlideContent { get; set; } = ExportSlideContent.Picture;
+
public ExportPageSize PageSize { get; set; } = ExportPageSize.A4;
///
@@ -97,6 +113,11 @@ public static ExportSettings Normalize(ExportSettings? settings)
result.PageSize = ExportPageSize.A4;
}
+ if (!Enum.IsDefined(result.SlideContent))
+ {
+ result.SlideContent = ExportSlideContent.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 46aea7b..3f4f894 100644
--- a/src/SQLBI.Whiteboard.Export/ExportPage.cs
+++ b/src/SQLBI.Whiteboard.Export/ExportPage.cs
@@ -3,8 +3,49 @@ namespace SQLBI.Whiteboard.Export;
///
/// One slide or page: a title, optional notes, and the picture that fills it. The pixel
/// size is what the layout scales by, so the PNG itself is never decoded on the way out.
+/// When is given, a slide is built from them instead of the
+/// picture, in the same pixel coordinate space, and the picture is kept for a page that
+/// cannot take elements.
///
-public sealed record ExportPage(string Title, string? Notes, byte[] Png, int PixelWidth, int PixelHeight);
+public sealed record ExportPage(
+ string Title,
+ string? Notes,
+ byte[] Png,
+ int PixelWidth,
+ int PixelHeight,
+ IReadOnlyList? Elements = null);
+
+///
+/// A rectangle in the page's pixel space: the same space the picture is measured in.
+///
+public readonly record struct SlideRect(double X, double Y, double Width, double Height);
+
+public abstract record SlideElement(SlideRect Bounds);
+
+///
+/// A picture placed as a picture: an image container, a LiveView frame, or the ink
+/// overlay that covers the whole page. Content type is "image/png" or "image/jpeg".
+///
+public sealed record SlideImageElement(SlideRect Bounds, byte[] Data, string ContentType) : SlideElement(Bounds);
+
+public sealed record SlideTextRun(string Text, uint Argb, bool Bold, bool Italic);
+
+///
+/// A text container as a text box: the title on its own first line, then the body as
+/// runs whose colors and weights are the ones the screen shows. A run may contain line
+/// breaks; each starts a new paragraph. Sizes and the padding are in page pixels.
+///
+public sealed record SlideTextElement(
+ SlideRect Bounds,
+ string Title,
+ IReadOnlyList Runs,
+ string FontFamily,
+ double TitleFontSize,
+ double BodyFontSize,
+ double Padding,
+ uint BackgroundArgb,
+ uint BorderArgb,
+ uint TextArgb) : SlideElement(Bounds);
/// 16:9 or 4:3.
public enum SlideAspect
diff --git a/src/SQLBI.Whiteboard.Export/PptxDeckWriter.cs b/src/SQLBI.Whiteboard.Export/PptxDeckWriter.cs
index 2730074..7a8bb7e 100644
--- a/src/SQLBI.Whiteboard.Export/PptxDeckWriter.cs
+++ b/src/SQLBI.Whiteboard.Export/PptxDeckWriter.cs
@@ -22,6 +22,9 @@ public static class PptxDeckWriter
private const long TitleTop = 228600;
private const long TitleHeight = 548640;
private const long PictureTop = 914400;
+ private const long EmuPerPoint = 12700;
+ private const int TextBoxBorderWidth = 9525;
+ private const double TextBoxCornerRadius = 4;
private const int TitleFontSize = 2000;
private const string TitleColor = "1F2937";
@@ -104,16 +107,24 @@ private static SlidePart AddSlide(
var slidePart = presentationPart.AddNewPart();
slidePart.AddPart(layoutPart);
- var imagePart = slidePart.AddImagePart(ImagePartType.Png);
- using (var png = new MemoryStream(page.Png, writable: false))
+ var fit = PageFit.Of(slideWidth, page.PixelWidth, page.PixelHeight);
+ var shapes = new List { TitleShape(slideWidth, page.Title) };
+ if (page.Elements is null)
{
- imagePart.FeedData(png);
+ var imageId = AddImage(slidePart, page.Png, ImagePartType.Png);
+ shapes.Add(PictureShape(PictureShapeId, "Picture", imageId, fit.Frame()));
+ }
+ else
+ {
+ // Elements are listed back to front, which is also the shape tree's z-order.
+ for (var index = 0; index < page.Elements.Count; index++)
+ {
+ shapes.Add(ElementShape(slidePart, page.Elements[index], PictureShapeId + (uint)index, fit));
+ }
}
slidePart.Slide = DeclareNamespaces(new P.Slide(
- new P.CommonSlideData(ShapeTree(
- TitleShape(slideWidth, page.Title),
- PictureShape(slidePart.GetIdOfPart(imagePart), slideWidth, page.PixelWidth, page.PixelHeight))),
+ new P.CommonSlideData(ShapeTree(shapes.ToArray())),
new P.ColorMapOverride(new A.MasterColorMapping())));
if (!string.IsNullOrEmpty(page.Notes))
@@ -127,23 +138,178 @@ private static SlidePart AddSlide(
return slidePart;
}
- private static P.Picture PictureShape(string imageRelationshipId, long slideWidth, int pixelWidth, int pixelHeight)
+ private static OpenXmlElement ElementShape(SlidePart slidePart, SlideElement element, uint id, PageFit fit) => element switch
+ {
+ SlideImageElement image => PictureShape(
+ id,
+ $"Picture {id}",
+ AddImage(slidePart, image.Data, ImageType(image.ContentType)),
+ fit.Frame(image.Bounds)),
+ SlideTextElement text => TextBoxShape(id, text, fit),
+ _ => throw new ArgumentException($"Unsupported slide element {element.GetType().Name}.", nameof(element)),
+ };
+
+ private static string AddImage(SlidePart slidePart, byte[] data, PartTypeInfo type)
+ {
+ var imagePart = slidePart.AddImagePart(type);
+ using (var stream = new MemoryStream(data, writable: false))
+ {
+ imagePart.FeedData(stream);
+ }
+
+ return slidePart.GetIdOfPart(imagePart);
+ }
+
+ private static PartTypeInfo ImageType(string contentType) =>
+ string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)
+ ? ImagePartType.Jpeg
+ : ImagePartType.Png;
+
+ private static P.Picture PictureShape(uint id, string name, string imageRelationshipId, A.Transform2D frame) => new(
+ new P.NonVisualPictureProperties(
+ new P.NonVisualDrawingProperties { Id = id, Name = name },
+ new P.NonVisualPictureDrawingProperties(new A.PictureLocks { NoChangeAspect = true }),
+ new P.ApplicationNonVisualDrawingProperties()),
+ new P.BlipFill(new A.Blip { Embed = imageRelationshipId }, new A.Stretch(new A.FillRectangle())),
+ new P.ShapeProperties(frame, Rectangle()));
+
+ ///
+ /// A text container as a plain text box: the title line in the deck's title face, then
+ /// the body runs. Autofit is off so that PowerPoint keeps the sizes the screen used.
+ ///
+ private static P.Shape TextBoxShape(uint id, SlideTextElement text, PageFit fit)
{
- var boxWidth = slideWidth - 2 * Margin;
- var boxHeight = SlideHeight - PictureTop - Margin;
- var scale = Math.Min((double)boxWidth / pixelWidth, (double)boxHeight / pixelHeight);
- var width = (long)Math.Round(pixelWidth * scale);
- var height = (long)Math.Round(pixelHeight * scale);
-
- return new P.Picture(
- new P.NonVisualPictureProperties(
- new P.NonVisualDrawingProperties { Id = PictureShapeId, Name = "Picture" },
- new P.NonVisualPictureDrawingProperties(new A.PictureLocks { NoChangeAspect = true }),
+ var titleSize = fit.Points(text.TitleFontSize);
+ var bodySize = fit.Points(text.BodyFontSize);
+ var inset = (int)fit.Emu(text.Padding);
+
+ var body = new P.TextBody(
+ new A.BodyProperties(new A.NoAutoFit())
+ {
+ Wrap = A.TextWrappingValues.Square,
+ LeftInset = inset,
+ TopInset = inset,
+ RightInset = inset,
+ BottomInset = inset,
+ Anchor = A.TextAnchoringTypeValues.Top,
+ },
+ new A.ListStyle());
+
+ var title = new A.Paragraph(new A.ParagraphProperties(
+ new A.SpaceAfter(new A.SpacingPoints { Val = (int)Math.Round(0.4 * bodySize) })));
+ if (text.Title.Length > 0)
+ {
+ title.Append(new A.Run(RunProperties(text.TextArgb, TitleFont, titleSize), new A.Text(text.Title)));
+ }
+
+ title.Append(new A.EndParagraphRunProperties { Language = Language, FontSize = titleSize });
+ body.Append(title);
+
+ var paragraph = new A.Paragraph();
+ foreach (var run in text.Runs)
+ {
+ var lines = run.Text.Split('\n');
+ for (var index = 0; index < lines.Length; index++)
+ {
+ if (index > 0)
+ {
+ paragraph.Append(new A.EndParagraphRunProperties { Language = Language, FontSize = bodySize });
+ body.Append(paragraph);
+ paragraph = new A.Paragraph();
+ }
+
+ var fragment = lines[index].TrimEnd('\r');
+ if (fragment.Length > 0)
+ {
+ paragraph.Append(new A.Run(
+ RunProperties(run.Argb, text.FontFamily, bodySize, run.Bold, run.Italic),
+ new A.Text(fragment)));
+ }
+ }
+ }
+
+ if (text.Runs.Count > 0)
+ {
+ paragraph.Append(new A.EndParagraphRunProperties { Language = Language, FontSize = bodySize });
+ body.Append(paragraph);
+ }
+
+ return new P.Shape(
+ new P.NonVisualShapeProperties(
+ new P.NonVisualDrawingProperties { Id = id, Name = $"Text {id}" },
+ new P.NonVisualShapeDrawingProperties { TextBox = true },
new P.ApplicationNonVisualDrawingProperties()),
- new P.BlipFill(new A.Blip { Embed = imageRelationshipId }, new A.Stretch(new A.FillRectangle())),
new P.ShapeProperties(
- Transform(Margin + (boxWidth - width) / 2, PictureTop + (boxHeight - height) / 2, width, height),
- Rectangle()));
+ fit.Frame(text.Bounds),
+ RoundedRectangle(text.Bounds),
+ new A.SolidFill(Rgb(text.BackgroundArgb)),
+ new A.Outline(new A.SolidFill(Rgb(text.BorderArgb))) { Width = TextBoxBorderWidth }),
+ body);
+ }
+
+ private static A.RunProperties RunProperties(uint argb, string typeface, int size, bool bold = false, bool italic = false)
+ {
+ var properties = new A.RunProperties(new A.SolidFill(Rgb(argb)), new A.LatinFont { Typeface = typeface })
+ {
+ Language = Language,
+ FontSize = size,
+ };
+ if (bold)
+ {
+ properties.Bold = true;
+ }
+
+ if (italic)
+ {
+ properties.Italic = true;
+ }
+
+ return properties;
+ }
+
+ // The adjust value is the corner radius as a fraction of the shorter side, in
+ // 1/100000, so it is derived from the bounds to keep the radius the screen shows.
+ private static A.PresetGeometry RoundedRectangle(SlideRect bounds)
+ {
+ var side = Math.Min(bounds.Width, bounds.Height);
+ var adjust = side > 0 ? Math.Clamp((int)Math.Round(TextBoxCornerRadius / side * 100000), 0, 50000) : 0;
+ return new A.PresetGeometry(new A.AdjustValueList(new A.ShapeGuide { Name = "adj", Formula = $"val {adjust}" }))
+ {
+ Preset = A.ShapeTypeValues.RoundRectangle,
+ };
+ }
+
+ ///
+ /// How a page's pixels land on the slide: scaled uniformly to fit the picture box and
+ /// centred in it. The single picture and every element go through the same mapping.
+ ///
+ private readonly record struct PageFit(double Scale, long OffsetX, long OffsetY, long Width, long Height)
+ {
+ public static PageFit Of(long slideWidth, int pixelWidth, int pixelHeight)
+ {
+ var boxWidth = slideWidth - 2 * Margin;
+ var boxHeight = SlideHeight - PictureTop - Margin;
+ var scale = Math.Min((double)boxWidth / pixelWidth, (double)boxHeight / pixelHeight);
+ var width = (long)Math.Round(pixelWidth * scale);
+ var height = (long)Math.Round(pixelHeight * scale);
+ return new PageFit(scale, Margin + (boxWidth - width) / 2, PictureTop + (boxHeight - height) / 2, width, height);
+ }
+
+ public A.Transform2D Frame() => Transform(OffsetX, OffsetY, Width, Height);
+
+ // Edges are rounded rather than sizes, so that a rectangle inside the page stays
+ // inside the picture box after rounding.
+ public A.Transform2D Frame(SlideRect rect)
+ {
+ var left = OffsetX + Emu(rect.X);
+ var top = OffsetY + Emu(rect.Y);
+ return Transform(left, top, OffsetX + Emu(rect.X + rect.Width) - left, OffsetY + Emu(rect.Y + rect.Height) - top);
+ }
+
+ public long Emu(double pixels) => (long)Math.Round(pixels * Scale);
+
+ // Hundredths of a point, the unit of sz and spcPts; never below 1 pt.
+ public int Points(double pixels) => Math.Max(100, (int)Math.Round(pixels * Scale / EmuPerPoint * 100));
}
///
@@ -398,4 +564,7 @@ private static A.PresetGeometry Rectangle() =>
new(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle };
private static A.RgbColorModelHex Rgb(string hex) => new() { Val = hex };
+
+ // DrawingML has no alpha in the hex; the screen's transparency is dropped.
+ private static A.RgbColorModelHex Rgb(uint argb) => Rgb((argb & 0xFFFFFF).ToString("X6"));
}
diff --git a/src/SQLBI.Whiteboard/BoardSurface.cs b/src/SQLBI.Whiteboard/BoardSurface.cs
index 912d39b..0011c88 100644
--- a/src/SQLBI.Whiteboard/BoardSurface.cs
+++ b/src/SQLBI.Whiteboard/BoardSurface.cs
@@ -28,6 +28,18 @@ internal sealed class BoardSurface : FrameworkElement
public Func? LiveViewImageSourceProvider { get; set; }
+ ///
+ /// Off for an export overlay, which is composed over the slide's own
+ /// objects and so must be transparent where nothing is drawn.
+ ///
+ public bool DrawBackground { get; set; } = true;
+
+ ///
+ /// When set, only the objects it accepts are drawn. An export uses it to
+ /// render the ink alone, once the containers have gone out as objects.
+ ///
+ public Func? ObjectFilter { get; set; }
+
///
/// The stroke the pen is drawing right now, before it is committed. Pen ink
/// is collected here rather than by the InkCanvas, so the wet stroke is
@@ -54,7 +66,10 @@ public void InvalidateAssets()
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
- drawingContext.DrawRectangle(BackgroundBrush, null, new Rect(RenderSize));
+ if (DrawBackground)
+ {
+ drawingContext.DrawRectangle(BackgroundBrush, null, new Rect(RenderSize));
+ }
if (_document is null || _camera is null)
{
@@ -63,7 +78,7 @@ protected override void OnRender(DrawingContext drawingContext)
foreach (var item in _document.Query(_camera.VisibleWorldBounds))
{
- if (item.Id == HiddenObjectId)
+ if (item.Id == HiddenObjectId || ObjectFilter?.Invoke(item) == false)
{
continue;
}
diff --git a/src/SQLBI.Whiteboard/Export/BoardExporter.cs b/src/SQLBI.Whiteboard/Export/BoardExporter.cs
index 6f3395d..7d19962 100644
--- a/src/SQLBI.Whiteboard/Export/BoardExporter.cs
+++ b/src/SQLBI.Whiteboard/Export/BoardExporter.cs
@@ -156,13 +156,18 @@ private static ExportPage RenderArea(
width,
height,
liveViewImageSourceProvider);
+ var editable = settings.Format == ExportFormat.PowerPoint &&
+ settings.SlideContent == ExportSlideContent.Editable;
return new ExportPage(
PageTitle(area, singlePageTitle),
settings.IncludeNotes ? Notes(area, titleResolver) : null,
WpfImageCodec.EncodePng(bitmap),
width,
- height);
+ height,
+ editable
+ ? EditableSlide.Build(document, area, width, height, liveViewImageSourceProvider)
+ : null);
}
///
diff --git a/src/SQLBI.Whiteboard/Export/BoardRasterizer.cs b/src/SQLBI.Whiteboard/Export/BoardRasterizer.cs
index a6a685d..37bb9dd 100644
--- a/src/SQLBI.Whiteboard/Export/BoardRasterizer.cs
+++ b/src/SQLBI.Whiteboard/Export/BoardRasterizer.cs
@@ -27,21 +27,22 @@ public static BitmapSource Render(
int pixelHeight,
Func? liveViewImageSourceProvider = null,
double paddingFraction = DefaultPaddingFraction,
- Action? overlay = null)
+ Action? overlay = null,
+ bool drawBackground = true,
+ Func? objectFilter = null)
{
ArgumentNullException.ThrowIfNull(document);
pixelWidth = Math.Max(1, pixelWidth);
pixelHeight = Math.Max(1, pixelHeight);
- var camera = new Camera2D();
- camera.Resize(pixelWidth, pixelHeight);
- camera.Frame(world, paddingFraction);
-
+ var camera = CameraFor(world, pixelWidth, pixelHeight, paddingFraction);
var surface = new BoardSurface
{
Width = pixelWidth,
Height = pixelHeight,
LiveViewImageSourceProvider = liveViewImageSourceProvider,
+ DrawBackground = drawBackground,
+ ObjectFilter = objectFilter,
};
surface.Configure(document, camera);
surface.Measure(new Size(pixelWidth, pixelHeight));
@@ -66,6 +67,23 @@ public static BitmapSource Render(
return bitmap;
}
+ ///
+ /// The camera a render of at this pixel size uses,
+ /// so that anything placed beside the picture lands where the picture
+ /// would have drawn it.
+ ///
+ public static Camera2D CameraFor(
+ RectD world,
+ int pixelWidth,
+ int pixelHeight,
+ double paddingFraction = DefaultPaddingFraction)
+ {
+ var camera = new Camera2D();
+ camera.Resize(Math.Max(1, pixelWidth), Math.Max(1, pixelHeight));
+ camera.Frame(world, paddingFraction);
+ return camera;
+ }
+
///
/// The pixel size that fits inside a box while
/// keeping the world aspect, so that a landscape area does not get a
diff --git a/src/SQLBI.Whiteboard/Export/EditableSlide.cs b/src/SQLBI.Whiteboard/Export/EditableSlide.cs
new file mode 100644
index 0000000..3f1362d
--- /dev/null
+++ b/src/SQLBI.Whiteboard/Export/EditableSlide.cs
@@ -0,0 +1,222 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using SQLBI.Whiteboard.Core.Export;
+using SQLBI.Whiteboard.Core.Geometry;
+using SQLBI.Whiteboard.Core.Model;
+using SQLBI.Whiteboard.Core.Viewport;
+
+namespace SQLBI.Whiteboard.Export;
+
+///
+/// An area as slide objects rather than as one picture: images and LiveView
+/// frames as pictures, text containers as text boxes with the colors the
+/// screen shows, and all the ink as one transparent picture on top. Every
+/// rectangle is in the pixel space of the picture the area would otherwise
+/// have been, so the two modes place things identically.
+///
+internal static class EditableSlide
+{
+ private const string PngContentType = "image/png";
+ private const string JpegContentType = "image/jpeg";
+
+ private const uint TextBackgroundArgb = 0xFFFCFCFC;
+ private const uint TextBorderArgb = 0xFFD6D9DE;
+ private const uint TextArgb = 0xFF1F2937;
+
+ public static IReadOnlyList Build(
+ BoardDocument document,
+ ExportArea area,
+ int pixelWidth,
+ int pixelHeight,
+ Func? liveViewImageSourceProvider)
+ {
+ var camera = BoardRasterizer.CameraFor(area.Bounds, pixelWidth, pixelHeight);
+ var elements = new List();
+ foreach (var item in area.Objects)
+ {
+ var element = item switch
+ {
+ ImageBoardObject image => ImageElement(document, image, camera),
+ LiveViewBoardObject liveView => LiveViewElement(document, liveView, camera, liveViewImageSourceProvider),
+ TextBoardObject text => TextElement(text, camera),
+ _ => null,
+ };
+ if (element is not null)
+ {
+ elements.Add(element);
+ }
+ }
+
+ if (area.Objects.OfType().Any())
+ {
+ var ink = BoardRasterizer.Render(
+ document,
+ area.Bounds,
+ pixelWidth,
+ pixelHeight,
+ liveViewImageSourceProvider,
+ drawBackground: false,
+ objectFilter: static item => item is InkStrokeObject);
+ elements.Add(new SlideImageElement(
+ new SlideRect(0, 0, pixelWidth, pixelHeight),
+ WpfImageCodec.EncodePng(ink),
+ PngContentType));
+ }
+
+ return elements;
+ }
+
+ private static SlideElement? ImageElement(BoardDocument document, ImageBoardObject image, Camera2D camera)
+ {
+ if (!document.Assets.TryGetValue(image.AssetId, out var asset))
+ {
+ return null;
+ }
+
+ var bounds = ToPage(image.Bounds, camera);
+ if (IsPng(asset.Data))
+ {
+ return new SlideImageElement(bounds, asset.Data, PngContentType);
+ }
+
+ if (IsJpeg(asset.Data))
+ {
+ return new SlideImageElement(bounds, asset.Data, JpegContentType);
+ }
+
+ // SVG, BMP, and GIF are drawn at the size they take on the slide, as
+ // the clipboard does; PowerPoint gets a PNG it cannot misread.
+ try
+ {
+ var decoded = BoardImageCodec.Decode(asset.Data);
+ var shown = new RectD(0, 0, Math.Max(1, bounds.Width), Math.Max(1, bounds.Height));
+ var bitmap = BoardImageCodec.Rasterize(decoded, shown);
+ return new SlideImageElement(bounds, WpfImageCodec.EncodePng(bitmap), PngContentType);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static SlideElement? LiveViewElement(
+ BoardDocument document,
+ LiveViewBoardObject liveView,
+ Camera2D camera,
+ Func? liveViewImageSourceProvider)
+ {
+ var bounds = ToPage(liveView.Bounds, camera);
+ if (liveViewImageSourceProvider?.Invoke(liveView.Id) is { } source)
+ {
+ var frame = source as BitmapSource ?? RenderImageSource(source, bounds);
+ return new SlideImageElement(bounds, WpfImageCodec.EncodePng(frame), PngContentType);
+ }
+
+ if (liveView.SnapshotAssetId is { } assetId &&
+ document.Assets.TryGetValue(assetId, out var snapshot) &&
+ IsPng(snapshot.Data))
+ {
+ return new SlideImageElement(bounds, snapshot.Data, PngContentType);
+ }
+
+ return null;
+ }
+
+ private static SlideElement TextElement(TextBoardObject text, Camera2D camera)
+ {
+ var language = TextLanguageRegistry.Resolve(text.LanguageId);
+ var analysis = language.Analyze(text.Text, text.Title);
+ var visualScale = double.IsFinite(text.VisualScale) && text.VisualScale > 0 ? text.VisualScale : 1;
+ var scale = visualScale * camera.Zoom;
+
+ return new SlideTextElement(
+ ToPage(text.Bounds, camera),
+ analysis.Title,
+ Runs(text.Text, analysis.Spans),
+ language.FontFamilyName,
+ TextContainerVisual.TitleFontSize * scale,
+ TextContainerVisual.BodyFontSize * scale,
+ TextContainerVisual.ContentPadding * scale,
+ TextBackgroundArgb,
+ TextBorderArgb,
+ TextArgb);
+ }
+
+ ///
+ /// The classification spans cover the tokens the language colors; the
+ /// text between them is a run in the default color, so the runs together
+ /// spell the whole text in order.
+ ///
+ private static IReadOnlyList Runs(string text, IReadOnlyList spans)
+ {
+ var runs = new List();
+ var cursor = 0;
+ foreach (var span in spans.OrderBy(span => span.Start))
+ {
+ var start = Math.Clamp(span.Start, cursor, text.Length);
+ var end = Math.Clamp(span.Start + span.Length, start, text.Length);
+ if (end <= start)
+ {
+ continue;
+ }
+
+ if (start > cursor)
+ {
+ runs.Add(new SlideTextRun(text[cursor..start], TextArgb, false, false));
+ }
+
+ runs.Add(new SlideTextRun(
+ text[start..end],
+ Argb(span.Style.Foreground),
+ span.Style.FontWeight.ToOpenTypeWeight() >= FontWeights.SemiBold.ToOpenTypeWeight(),
+ span.Style.FontStyle == FontStyles.Italic));
+ cursor = end;
+ }
+
+ if (cursor < text.Length)
+ {
+ runs.Add(new SlideTextRun(text[cursor..], TextArgb, false, false));
+ }
+
+ return runs;
+ }
+
+ private static uint Argb(Brush brush) => brush is SolidColorBrush { Color: var color }
+ ? ((uint)color.A << 24) | ((uint)color.R << 16) | ((uint)color.G << 8) | color.B
+ : TextArgb;
+
+ private static SlideRect ToPage(RectD bounds, Camera2D camera)
+ {
+ var topLeft = camera.WorldToScreen(new PointD(bounds.Left, bounds.Top));
+ var bottomRight = camera.WorldToScreen(new PointD(bounds.Right, bounds.Bottom));
+ return new SlideRect(
+ topLeft.X,
+ topLeft.Y,
+ Math.Max(1, bottomRight.X - topLeft.X),
+ Math.Max(1, bottomRight.Y - topLeft.Y));
+ }
+
+ private static BitmapSource RenderImageSource(ImageSource source, SlideRect bounds)
+ {
+ var width = (int)Math.Clamp(Math.Round(bounds.Width), 1, 8192);
+ var height = (int)Math.Clamp(Math.Round(bounds.Height), 1, 8192);
+ var visual = new DrawingVisual();
+ using (var context = visual.RenderOpen())
+ {
+ context.DrawImage(source, new Rect(0, 0, width, height));
+ }
+
+ var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+ bitmap.Freeze();
+ return bitmap;
+ }
+
+ private static bool IsPng(byte[] bytes) =>
+ bytes.Length > 8 &&
+ bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47;
+
+ private static bool IsJpeg(byte[] bytes) =>
+ bytes.Length > 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
+}
diff --git a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml
index 6f1cb96..dec6499 100644
--- a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml
+++ b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml
@@ -156,6 +156,18 @@
ItemContainerStyle="{StaticResource SettingsComboBoxItem}"
SelectionChanged="Option_Changed" />
+
+
+
+
diff --git a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs
index adc1795..8756665 100644
--- a/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs
+++ b/src/SQLBI.Whiteboard/Export/ExportWindow.xaml.cs
@@ -121,6 +121,10 @@ private void PopulateOptions()
Select(SmallestTextCombo, _settings.SmallestTextPoints);
+ ContentCombo.Items.Add(new Choice("Picture", ExportSlideContent.Picture));
+ ContentCombo.Items.Add(new Choice("Editable", ExportSlideContent.Editable));
+ Select(ContentCombo, _settings.SlideContent);
+
AspectCombo.Items.Add(new Choice("Widescreen (16:9)", ExportSlideAspect.Wide));
AspectCombo.Items.Add(new Choice("Standard (4:3)", ExportSlideAspect.Standard));
Select(AspectCombo, _settings.SlideAspect);
@@ -171,6 +175,7 @@ private void ReadOptions()
_settings.Order = Selected(OrderCombo, AreaOrder.Drawing);
_settings.SmallestTextPoints = Selected(SmallestTextCombo, ExportLayoutOptions.DefaultSmallestTextPoints);
_settings.SlideAspect = Selected(AspectCombo, ExportSlideAspect.Wide);
+ _settings.SlideContent = Selected(ContentCombo, ExportSlideContent.Picture);
_settings.PageSize = Selected(PageSizeCombo, ExportPageSize.A4);
_settings.GapThreshold = GapSlider.Value;
_settings.IncludeOverview = OverviewSwitch.IsChecked == true;
@@ -191,6 +196,9 @@ private void Rebuild()
OverviewLabel.Text = "Overview " + unit + " first";
AspectLabel.Visibility = pdf ? Visibility.Collapsed : Visibility.Visible;
AspectCombo.Visibility = AspectLabel.Visibility;
+ ContentLabel.Visibility = AspectLabel.Visibility;
+ ContentCombo.Visibility = AspectLabel.Visibility;
+ ContentHint.Visibility = AspectLabel.Visibility;
NotesRow.Visibility = AspectLabel.Visibility;
PageSizeLabel.Visibility = pdf ? Visibility.Visible : Visibility.Collapsed;
PageSizeCombo.Visibility = PageSizeLabel.Visibility;
diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
index bfc431b..851a24e 100644
--- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs
@@ -1283,6 +1283,52 @@ string SqlText(SqlServerClassifiedSpan span) =>
PptxDeckWriter.Write(standardStream, deckPages[..1], new DeckOptions(SlideAspect.Standard));
Assert(standardStream.Length > 0, "A 4:3 deck is written too.");
+ // An editable slide: the picture gives way to a picture per image, a text
+ // box per text container, and the ink overlay on top.
+ SlideElement[] elements =
+ [
+ new SlideImageElement(new SlideRect(100, 100, 400, 300), onePixelPng, "image/png"),
+ new SlideTextElement(
+ new SlideRect(600, 100, 500, 200),
+ "DAX Code of Sales Amount",
+ [
+ new SlideTextRun("Sales Amount", 0xFF202020, true, false),
+ new SlideTextRun(" :=\n", 0xFF5E6470, false, false),
+ new SlideTextRun("SUM", 0xFF035ACA, true, false),
+ new SlideTextRun(" ( Sales[Amount] )\n\n-- done", 0xFF333333, false, true),
+ ],
+ "Consolas",
+ 13,
+ 18,
+ 10,
+ 0xFFFCFCFC,
+ 0xFFD6D9DE,
+ 0xFF1F2937),
+ new SlideImageElement(new SlideRect(0, 0, 1600, 900), onePixelPng, "image/png"),
+ ];
+ using var editableStream = new MemoryStream();
+ PptxDeckWriter.Write(
+ editableStream,
+ [new ExportPage("Editable", null, onePixelPng, 1600, 900, elements)],
+ new DeckOptions());
+ editableStream.Position = 0;
+ using (var editable = new ZipArchive(editableStream, ZipArchiveMode.Read, leaveOpen: true))
+ {
+ using var reader = new StreamReader(editable.GetEntry("ppt/slides/slide1.xml")!.Open());
+ var slideXml = reader.ReadToEnd();
+ Assert(
+ slideXml.Split("").Length - 1 == 2 && slideXml.Split("").Length - 1 == 2,
+ "An editable slide holds two pictures, the title, and one text box.");
+ Assert(
+ slideXml.Contains("Sales Amount", StringComparison.Ordinal) &&
+ slideXml.Contains("035ACA", StringComparison.Ordinal) &&
+ slideXml.Contains("Consolas", StringComparison.Ordinal),
+ "Text runs keep their words, colors, and typeface.");
+ Assert(
+ editable.Entries.Count(entry => entry.FullName.StartsWith("ppt/media/", StringComparison.Ordinal)) == 2,
+ "Each picture element has its own image part.");
+ }
+
// The same pages as a PDF: one page each, a bookmark each, and the page
// size following the picture when asked.
using var pdfStream = new MemoryStream();