@agent-native/core 0.149.0 → 0.149.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/slides/actions/patch-deck.ts +18 -2
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +46 -0
- package/corpus/templates/slides/app/components/editor/BlockBubbleMenu.tsx +194 -3
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +10 -1
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +5 -3
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +26 -4
- package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +45 -0
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +84 -25
- package/corpus/templates/slides/app/lib/import-uploaded-deck.ts +11 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -0
- package/corpus/templates/slides/app/pages/Index.tsx +16 -9
- package/corpus/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md +6 -0
- package/corpus/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md +6 -0
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +115 -22
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +11 -2
- package/corpus/templates/slides/server/lib/fetch-remote-image.ts +192 -0
- package/corpus/templates/slides/server/lib/image-proxy-url.ts +192 -0
- package/corpus/templates/slides/server/routes/api/image-proxy.get.ts +65 -0
- package/dist/client/progress/RunsTray.js +14 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/package.json +1 -1
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
* largest rendered element so a thumbnail's transform: scale(0.25)
|
|
11
11
|
* doesn't shrink the captured pixels.
|
|
12
12
|
*/
|
|
13
|
+
import { appBasePath } from "@agent-native/core/client/api-path";
|
|
14
|
+
|
|
13
15
|
import { type AspectRatio, getAspectRatioDims } from "./aspect-ratios";
|
|
14
16
|
import { importExportModule } from "./dynamic-import";
|
|
15
17
|
|
|
18
|
+
/** Same-origin URL that re-serves a remote image, bypassing its missing CORS. */
|
|
19
|
+
export function imageProxyUrl(src: string): string {
|
|
20
|
+
return `${appBasePath()}/api/image-proxy?url=${encodeURIComponent(src)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
/**
|
|
17
24
|
* Cross-origin <img> elements without an explicit `crossOrigin="anonymous"`
|
|
18
25
|
* attribute taint the canvas when rasterized via <foreignObject>, producing
|
|
@@ -21,8 +28,15 @@ import { importExportModule } from "./dynamic-import";
|
|
|
21
28
|
* setting the attribute and re-assigning the same src. This is the root
|
|
22
29
|
* cause of the "blank images in exported PDF" bug Rochkind reported.
|
|
23
30
|
*/
|
|
24
|
-
export async function preloadImagesWithCors(
|
|
31
|
+
export async function preloadImagesWithCors(
|
|
32
|
+
root: HTMLElement,
|
|
33
|
+
): Promise<() => void> {
|
|
25
34
|
const imgs = Array.from(root.querySelectorAll<HTMLImageElement>("img"));
|
|
35
|
+
// The slide DOM is the live editor canvas. Anything rewritten here would
|
|
36
|
+
// otherwise be picked up by the next save and persisted into the deck, so
|
|
37
|
+
// every mutation is recorded and undone once the capture is done.
|
|
38
|
+
const restores: Array<() => void> = [];
|
|
39
|
+
|
|
26
40
|
await Promise.all(
|
|
27
41
|
imgs.map(async (img) => {
|
|
28
42
|
const src = img.currentSrc || img.src;
|
|
@@ -35,30 +49,59 @@ export async function preloadImagesWithCors(root: HTMLElement): Promise<void> {
|
|
|
35
49
|
isCrossOrigin = false;
|
|
36
50
|
}
|
|
37
51
|
if (!isCrossOrigin) return;
|
|
52
|
+
|
|
53
|
+
const originalCrossOrigin = img.getAttribute("crossorigin");
|
|
54
|
+
const originalSrc = img.getAttribute("src");
|
|
55
|
+
const restore = () => {
|
|
56
|
+
if (originalCrossOrigin === null) img.removeAttribute("crossorigin");
|
|
57
|
+
else img.setAttribute("crossorigin", originalCrossOrigin);
|
|
58
|
+
if (originalSrc === null) img.removeAttribute("src");
|
|
59
|
+
else img.setAttribute("src", originalSrc);
|
|
60
|
+
};
|
|
61
|
+
|
|
38
62
|
if (img.crossOrigin === "anonymous") {
|
|
39
63
|
// Already CORS-enabled; just make sure it's decoded.
|
|
40
64
|
try {
|
|
41
65
|
await img.decode();
|
|
66
|
+
return;
|
|
42
67
|
} catch {
|
|
43
|
-
|
|
68
|
+
// coercion-ok: a failed direct load is the signal to try the proxy,
|
|
69
|
+
// and the proxy attempt below reports its own failure.
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
img.crossOrigin = "anonymous";
|
|
73
|
+
// Re-set src to retrigger the load with the new CORS attribute.
|
|
74
|
+
img.src = src;
|
|
75
|
+
restores.push(restore);
|
|
76
|
+
try {
|
|
77
|
+
await img.decode();
|
|
78
|
+
return;
|
|
79
|
+
} catch {
|
|
80
|
+
// coercion-ok: same as above — this is the CORS probe, not the
|
|
81
|
+
// final outcome.
|
|
44
82
|
}
|
|
45
|
-
return;
|
|
46
83
|
}
|
|
84
|
+
|
|
85
|
+
// The host does not send Access-Control-Allow-Origin, and no client-side
|
|
86
|
+
// flag can override that. Re-serve the image from our own origin so the
|
|
87
|
+
// canvas stays clean instead of rasterizing a blank rect.
|
|
88
|
+
if (!restores.includes(restore)) restores.push(restore);
|
|
47
89
|
img.crossOrigin = "anonymous";
|
|
48
|
-
|
|
49
|
-
img.src = src;
|
|
90
|
+
img.src = imageProxyUrl(src);
|
|
50
91
|
try {
|
|
51
92
|
await img.decode();
|
|
52
93
|
} catch (err) {
|
|
53
|
-
// Server didn't return Access-Control-Allow-Origin. The screenshot
|
|
54
|
-
// will be blank for this image — log so the user can swap the host.
|
|
55
94
|
console.warn(
|
|
56
|
-
`[export-pdf]
|
|
95
|
+
`[export-pdf] image could not be loaded for export: ${src}`,
|
|
57
96
|
err,
|
|
58
97
|
);
|
|
59
98
|
}
|
|
60
99
|
}),
|
|
61
100
|
);
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
for (const restore of restores) restore();
|
|
104
|
+
};
|
|
62
105
|
}
|
|
63
106
|
|
|
64
107
|
export function findSlideExportSource(
|
|
@@ -132,23 +175,39 @@ export async function exportDeckAsPdf(
|
|
|
132
175
|
// Force CORS-enabled re-fetch on every cross-origin <img> before
|
|
133
176
|
// capture — otherwise the canvas tainting check inside modern-screenshot
|
|
134
177
|
// produces a blank rect for the image.
|
|
135
|
-
await preloadImagesWithCors(source);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
178
|
+
const restoreImages = await preloadImagesWithCors(source);
|
|
179
|
+
|
|
180
|
+
let dataUrl: string;
|
|
181
|
+
try {
|
|
182
|
+
dataUrl = await domToJpeg(source, {
|
|
183
|
+
width: dims.width,
|
|
184
|
+
height: dims.height,
|
|
185
|
+
scale: 2, // 2x for crisp text
|
|
186
|
+
// guard:allow-raw-color — a PDF page has no theme to follow.
|
|
187
|
+
backgroundColor: "#000000",
|
|
188
|
+
quality: 0.92,
|
|
189
|
+
// Pair with the in-DOM CORS preload above. modern-screenshot's
|
|
190
|
+
// internal image fetcher needs no-cache so re-issued requests don't
|
|
191
|
+
// get served the original tainted (no-CORS) response from the HTTP
|
|
192
|
+
// cache, and an anonymous-CORS request mode so the response itself
|
|
193
|
+
// is usable on a clean canvas.
|
|
194
|
+
//
|
|
195
|
+
// `same-origin` rather than `omit`: images the preload rewrote to
|
|
196
|
+
// /api/image-proxy are same-origin and that route needs the session
|
|
197
|
+
// cookie, so omitting credentials would 401 exactly the images this
|
|
198
|
+
// is meant to rescue. Cross-origin requests still go out anonymously,
|
|
199
|
+
// which is what CORS mode requires.
|
|
200
|
+
fetch: {
|
|
201
|
+
requestInit: {
|
|
202
|
+
cache: "no-cache",
|
|
203
|
+
mode: "cors",
|
|
204
|
+
credentials: "same-origin",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
} finally {
|
|
209
|
+
restoreImages();
|
|
210
|
+
}
|
|
152
211
|
|
|
153
212
|
if (i > 0) pdf.addPage([dims.width, dims.height], orientation);
|
|
154
213
|
pdf.addImage(dataUrl, "JPEG", 0, 0, dims.width, dims.height);
|
|
@@ -9,6 +9,16 @@ export type ImportedSourceDeck = {
|
|
|
9
9
|
imagesSkipped: number;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* PDF/PPTX import renders every page (image extraction, per-page fidelity
|
|
14
|
+
* parsing) and can run well past the client's default 60s action timeout on
|
|
15
|
+
* large or image-heavy files. A timeout here only aborts the client's wait —
|
|
16
|
+
* the server keeps importing and the deck still ends up with slides — so the
|
|
17
|
+
* default made the editor silently fail on a deck that had, or was about to
|
|
18
|
+
* have, real content.
|
|
19
|
+
*/
|
|
20
|
+
export const IMPORT_ACTION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
12
22
|
function sourceFormat(file: UploadedFile): "pdf" | "pptx" | null {
|
|
13
23
|
const name = file.originalName.toLowerCase();
|
|
14
24
|
if (name.endsWith(".pptx")) return "pptx";
|
|
@@ -57,6 +67,7 @@ export async function importUploadedDeckIntoDeck(
|
|
|
57
67
|
deckId,
|
|
58
68
|
importIntoDeck: true,
|
|
59
69
|
},
|
|
70
|
+
{ timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
|
|
60
71
|
),
|
|
61
72
|
);
|
|
62
73
|
if (result.imported !== true || result.deckId !== deckId) {
|
|
@@ -345,6 +345,18 @@ export default function DeckEditor() {
|
|
|
345
345
|
if (!generating) setAddSlideGenerating(false);
|
|
346
346
|
}, [generating]);
|
|
347
347
|
|
|
348
|
+
// Below `md` the rail is a drawer behind a full-viewport dimming scrim; at
|
|
349
|
+
// `md` and up it's docked with no scrim. `sidebarOpen` is seeded from the
|
|
350
|
+
// width at mount only, so a window that starts wide and is then narrowed
|
|
351
|
+
// (or an editor opened in a resizable preview pane) keeps `sidebarOpen`
|
|
352
|
+
// true while the scrim stops being `md:hidden` — dimming the whole editor
|
|
353
|
+
// with no way to dismiss it.
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const onResize = () => setSidebarOpen(window.innerWidth >= 768);
|
|
356
|
+
window.addEventListener("resize", onResize);
|
|
357
|
+
return () => window.removeEventListener("resize", onResize);
|
|
358
|
+
}, []);
|
|
359
|
+
|
|
348
360
|
const previousSlideIdsRef = useRef<string[]>([]);
|
|
349
361
|
useEffect(() => {
|
|
350
362
|
const currentSlideIds = deck?.slides.map((slide) => slide.id) ?? [];
|
|
@@ -59,6 +59,7 @@ import { createDeckAgentMessage } from "@/lib/agent-visible-message";
|
|
|
59
59
|
import { savePromptToComposerDraft } from "@/lib/composer-draft";
|
|
60
60
|
import { sortDecksByRecency } from "@/lib/deck-sorting";
|
|
61
61
|
import {
|
|
62
|
+
IMPORT_ACTION_TIMEOUT_MS,
|
|
62
63
|
importUploadedDeckIntoDeck,
|
|
63
64
|
type ImportedSourceDeck,
|
|
64
65
|
} from "@/lib/import-uploaded-deck";
|
|
@@ -813,9 +814,11 @@ export default function Index() {
|
|
|
813
814
|
let importedReference: ImportedReference | null = null;
|
|
814
815
|
let generationFiles = uploaded;
|
|
815
816
|
if (pptxReference) {
|
|
816
|
-
const imported = (await callAction(
|
|
817
|
-
|
|
818
|
-
|
|
817
|
+
const imported = (await callAction(
|
|
818
|
+
"import-pptx",
|
|
819
|
+
{ filePath: pptxReference.path },
|
|
820
|
+
{ timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
|
|
821
|
+
)) as {
|
|
819
822
|
id?: unknown;
|
|
820
823
|
imported?: unknown;
|
|
821
824
|
slideCount?: unknown;
|
|
@@ -854,12 +857,16 @@ export default function Index() {
|
|
|
854
857
|
);
|
|
855
858
|
}
|
|
856
859
|
try {
|
|
857
|
-
const imported = (await callAction(
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
860
|
+
const imported = (await callAction(
|
|
861
|
+
"import-file",
|
|
862
|
+
{
|
|
863
|
+
filePath: pdfReference.path,
|
|
864
|
+
format: "pdf",
|
|
865
|
+
deckId: referenceDeck.id,
|
|
866
|
+
importIntoDeck: true,
|
|
867
|
+
},
|
|
868
|
+
{ timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
|
|
869
|
+
)) as {
|
|
863
870
|
imported?: unknown;
|
|
864
871
|
deckId?: unknown;
|
|
865
872
|
pageCount?: unknown;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: fixed
|
|
3
|
+
date: 2026-08-09
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Fixed direct edits to a PDF/PPTX-imported deck's slide text sometimes failing to save with a generic "Internal server error" — the source-preservation guard meant to stop an agent from silently dropping the original artwork or copy was also blocking ordinary human edits, which have no way to opt out of it.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: fixed
|
|
3
|
+
date: 2026-08-09
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Fixed images vanishing from exported PDFs and PowerPoint files when they came from a site that blocks direct browser access, such as a blog or a stock photo host. Those images are now fetched through the app and appear in the export.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: fixed
|
|
3
|
+
date: 2026-08-09
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Fixed imported PDF/PowerPoint slide text rendering larger than its original box (often overlapping neighboring text) whenever the source page's physical size didn't match the deck canvas's assumed size — font sizes now scale by the same factor as element positions instead of a fixed point-to-pixel conversion.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: fixed
|
|
3
|
+
date: 2026-08-09
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Fixed decks with many slides loading slowly and rendering incorrectly in the editor. Every slide thumbnail measured its full layout on mount, so a long deck forced hundreds of page reflows at once; off-screen thumbnails now wait until they scroll into view, and the browser skips painting them entirely until then. The hover buttons on each thumbnail also no longer blur what is behind them, which was making the rail flicker and leaving dark patches over the editor.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ASPECT_RATIOS } from "@shared/aspect-ratios";
|
|
2
|
+
|
|
1
3
|
import type {
|
|
2
4
|
ParsedElement,
|
|
3
5
|
ParsedParagraph,
|
|
@@ -165,10 +167,37 @@ export function convertToSlideHtml(
|
|
|
165
167
|
|
|
166
168
|
const DEFAULT_SLIDE_WIDTH_EMU = 9144000;
|
|
167
169
|
const DEFAULT_SLIDE_HEIGHT_EMU = 5143500;
|
|
168
|
-
const CSS_PX_PER_POINT = 96 / 72;
|
|
169
170
|
const DEFAULT_PPTX_BACKGROUND = "#000000"; // guard:allow-raw-color - preserve PPTX black when no background is declared
|
|
170
171
|
const DEFAULT_PPTX_FOREGROUND = "#ffffff"; // guard:allow-raw-color - preserve PPTX white when no run color is declared
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The absolute px box `toSlidePxX`/`toSlidePxY` scale positions and sizes
|
|
175
|
+
* against. It must match the aspect-ratio preset the deck actually renders
|
|
176
|
+
* into (`ASPECT_RATIOS`, chosen by the import actions' own
|
|
177
|
+
* `nearestAspectRatio`) rather than a fixed 16:9 box: a PDF page or a custom
|
|
178
|
+
* PPTX slide size is routinely portrait or square, and scaling its elements
|
|
179
|
+
* against a 960x540 reference while the deck itself renders in an 864x1080
|
|
180
|
+
* (or other) box stretches every element by the ratio between the two
|
|
181
|
+
* boxes, most visibly squashing everything into the top fraction of a
|
|
182
|
+
* taller-than-540 canvas.
|
|
183
|
+
*/
|
|
184
|
+
function referenceBoxForSlide(
|
|
185
|
+
widthEmu: number,
|
|
186
|
+
heightEmu: number,
|
|
187
|
+
): { width: number; height: number } {
|
|
188
|
+
const target = widthEmu / heightEmu;
|
|
189
|
+
let best: { width: number; height: number } = ASPECT_RATIOS["16:9"];
|
|
190
|
+
let bestDiff = Infinity;
|
|
191
|
+
for (const preset of Object.values(ASPECT_RATIOS)) {
|
|
192
|
+
const diff = Math.abs(preset.width / preset.height - target);
|
|
193
|
+
if (diff < bestDiff) {
|
|
194
|
+
bestDiff = diff;
|
|
195
|
+
best = preset;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { width: best.width, height: best.height };
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
function buildFidelitySlide(
|
|
173
202
|
slide: ParsedSlide,
|
|
174
203
|
imageUrls: string | Record<string, string> | undefined,
|
|
@@ -176,9 +205,10 @@ function buildFidelitySlide(
|
|
|
176
205
|
): string {
|
|
177
206
|
const widthEmu = slide.widthEmu || DEFAULT_SLIDE_WIDTH_EMU;
|
|
178
207
|
const heightEmu = slide.heightEmu || DEFAULT_SLIDE_HEIGHT_EMU;
|
|
208
|
+
const refBox = referenceBoxForSlide(widthEmu, heightEmu);
|
|
179
209
|
const background = slide.backgroundColor ?? DEFAULT_PPTX_BACKGROUND;
|
|
180
210
|
const gridStyle = slide.backgroundGrid
|
|
181
|
-
? `background-image:linear-gradient(to right, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu))}px, transparent ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu))}px),linear-gradient(to bottom, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu))}px, transparent ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu))}px);background-size:${toSlidePxX(slide.backgroundGrid.stepXEmu, widthEmu)}px ${toSlidePxY(slide.backgroundGrid.stepYEmu, heightEmu)}px;background-position:${toSlidePxX(slide.backgroundGrid.offsetXEmu, widthEmu)}px ${toSlidePxY(slide.backgroundGrid.offsetYEmu, heightEmu)}px;background-repeat:repeat;`
|
|
211
|
+
? `background-image:linear-gradient(to right, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu, refBox.width))}px, transparent ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu, refBox.width))}px),linear-gradient(to bottom, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu, refBox.height))}px, transparent ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu, refBox.height))}px);background-size:${toSlidePxX(slide.backgroundGrid.stepXEmu, widthEmu, refBox.width)}px ${toSlidePxY(slide.backgroundGrid.stepYEmu, heightEmu, refBox.height)}px;background-position:${toSlidePxX(slide.backgroundGrid.offsetXEmu, widthEmu, refBox.width)}px ${toSlidePxY(slide.backgroundGrid.offsetYEmu, heightEmu, refBox.height)}px;background-repeat:repeat;`
|
|
182
212
|
: "";
|
|
183
213
|
const elements = slide.elements ?? [];
|
|
184
214
|
const html = elements
|
|
@@ -188,6 +218,7 @@ function buildFidelitySlide(
|
|
|
188
218
|
index,
|
|
189
219
|
widthEmu,
|
|
190
220
|
heightEmu,
|
|
221
|
+
refBox,
|
|
191
222
|
imageUrls,
|
|
192
223
|
themeFont,
|
|
193
224
|
),
|
|
@@ -203,10 +234,11 @@ function buildFidelityElement(
|
|
|
203
234
|
index: number,
|
|
204
235
|
widthEmu: number,
|
|
205
236
|
heightEmu: number,
|
|
237
|
+
refBox: { width: number; height: number },
|
|
206
238
|
imageUrls: string | Record<string, string> | undefined,
|
|
207
239
|
themeFont: string | undefined,
|
|
208
240
|
): string {
|
|
209
|
-
const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu)}px; top: ${toSlidePxY(element.y, heightEmu)}px; width: ${toSlidePxX(element.width, widthEmu)}px; height: ${toSlidePxY(element.height, heightEmu)}px; z-index: ${index}; box-sizing: border-box;`;
|
|
241
|
+
const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu, refBox.width)}px; top: ${toSlidePxY(element.y, heightEmu, refBox.height)}px; width: ${toSlidePxX(element.width, widthEmu, refBox.width)}px; height: ${toSlidePxY(element.height, heightEmu, refBox.height)}px; z-index: ${index}; box-sizing: border-box;`;
|
|
210
242
|
const rotation = element.rotation
|
|
211
243
|
? ` transform: rotate(${element.rotation}deg); transform-origin: center center;`
|
|
212
244
|
: "";
|
|
@@ -218,12 +250,18 @@ function buildFidelityElement(
|
|
|
218
250
|
return `<div class="fmd-pptx-image" data-pptx-element-kind="image" data-pptx-image-name="${esc(element.image?.name ?? "image")}"${objectId} style="${position}${rotation} overflow: hidden;">${url ? `<img src="${esc(url)}" alt="" style="${imageStyle}" />` : `<div class="fmd-img-placeholder" style="width:100%;height:100%;">Imported image: ${esc(element.image?.name ?? "image")}</div>`}</div>`;
|
|
219
251
|
}
|
|
220
252
|
|
|
221
|
-
const decoration = shapeDecoration(element, widthEmu);
|
|
253
|
+
const decoration = shapeDecoration(element, widthEmu, refBox.width);
|
|
222
254
|
if (element.kind === "shape") {
|
|
223
255
|
return `<div class="fmd-pptx-shape" data-pptx-element-kind="shape"${objectId} style="${position}${rotation}${decoration}"></div>`;
|
|
224
256
|
}
|
|
225
257
|
|
|
226
|
-
const textStyle = textBoxStyle(
|
|
258
|
+
const textStyle = textBoxStyle(
|
|
259
|
+
element,
|
|
260
|
+
widthEmu,
|
|
261
|
+
heightEmu,
|
|
262
|
+
refBox,
|
|
263
|
+
themeFont,
|
|
264
|
+
);
|
|
227
265
|
const defaultFontWeight = element.placeholderType === "title" ? 700 : 400;
|
|
228
266
|
const paragraphs = (element.paragraphs ?? [])
|
|
229
267
|
.map((paragraph, paragraphIndex) =>
|
|
@@ -231,6 +269,7 @@ function buildFidelityElement(
|
|
|
231
269
|
paragraph,
|
|
232
270
|
paragraphIndex,
|
|
233
271
|
widthEmu,
|
|
272
|
+
refBox.width,
|
|
234
273
|
themeFont,
|
|
235
274
|
defaultFontWeight,
|
|
236
275
|
),
|
|
@@ -239,12 +278,41 @@ function buildFidelityElement(
|
|
|
239
278
|
return `<div class="fmd-pptx-text" data-pptx-element-kind="text"${objectId} style="${position}${rotation}${decoration}${textStyle}">${paragraphs}</div>`;
|
|
240
279
|
}
|
|
241
280
|
|
|
242
|
-
function toSlidePxX(
|
|
243
|
-
|
|
281
|
+
function toSlidePxX(
|
|
282
|
+
valueEmu: number,
|
|
283
|
+
slideWidthEmu: number,
|
|
284
|
+
refWidthPx: number,
|
|
285
|
+
): number {
|
|
286
|
+
return Math.round((valueEmu / slideWidthEmu) * refWidthPx * 1000) / 1000;
|
|
244
287
|
}
|
|
245
288
|
|
|
246
|
-
function toSlidePxY(
|
|
247
|
-
|
|
289
|
+
function toSlidePxY(
|
|
290
|
+
valueEmu: number,
|
|
291
|
+
slideHeightEmu: number,
|
|
292
|
+
refHeightPx: number,
|
|
293
|
+
): number {
|
|
294
|
+
return Math.round((valueEmu / slideHeightEmu) * refHeightPx * 1000) / 1000;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const EMU_PER_POINT = 12700;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A run's font size (and paragraph spacing) is stored in points, a physical
|
|
301
|
+
* unit independent of the source slide's own canvas size — unlike
|
|
302
|
+
* position/size EMUs, a fixed `pt * 96/72` conversion doesn't know how far
|
|
303
|
+
* `toSlidePxX`/`toSlidePxY` scaled that canvas down (or up) to fit the
|
|
304
|
+
* deck's aspect-ratio box. Converting the point value to EMU first and
|
|
305
|
+
* running it through the same `toSlidePxX` scale keeps text sized
|
|
306
|
+
* proportionally to its box on every source slide size, not just the one
|
|
307
|
+
* physical size (10in wide) that happens to make the fixed conversion agree
|
|
308
|
+
* with the 16:9 preset's box.
|
|
309
|
+
*/
|
|
310
|
+
function ptToSlidePx(
|
|
311
|
+
valuePt: number,
|
|
312
|
+
widthEmu: number,
|
|
313
|
+
refWidthPx: number,
|
|
314
|
+
): number {
|
|
315
|
+
return toSlidePxX(valuePt * EMU_PER_POINT, widthEmu, refWidthPx);
|
|
248
316
|
}
|
|
249
317
|
|
|
250
318
|
function imageUrlForElement(
|
|
@@ -263,10 +331,14 @@ function imageRenderStyle(element: ParsedElement): string {
|
|
|
263
331
|
return `display:block;position:absolute;left:${(-crop.left / visibleWidth) * 100}%;top:${(-crop.top / visibleHeight) * 100}%;width:${(1 / visibleWidth) * 100}%;height:${(1 / visibleHeight) * 100}%;object-fit:fill;`;
|
|
264
332
|
}
|
|
265
333
|
|
|
266
|
-
function shapeDecoration(
|
|
334
|
+
function shapeDecoration(
|
|
335
|
+
element: ParsedElement,
|
|
336
|
+
widthEmu: number,
|
|
337
|
+
refWidthPx: number,
|
|
338
|
+
): string {
|
|
267
339
|
const fill = element.fill ? `background: ${esc(element.fill)};` : "";
|
|
268
340
|
const line = element.lineColor
|
|
269
|
-
? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu))}px solid ${esc(element.lineColor)};`
|
|
341
|
+
? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx))}px solid ${esc(element.lineColor)};`
|
|
270
342
|
: "";
|
|
271
343
|
const radius = element.shapeType === "roundRect" ? "border-radius: 6px;" : "";
|
|
272
344
|
return `${fill}${line}${radius}`;
|
|
@@ -276,13 +348,16 @@ function textBoxStyle(
|
|
|
276
348
|
element: ParsedElement,
|
|
277
349
|
widthEmu: number,
|
|
278
350
|
heightEmu: number,
|
|
351
|
+
refBox: { width: number; height: number },
|
|
279
352
|
themeFont: string | undefined,
|
|
280
353
|
): string {
|
|
281
354
|
const padding = element.padding;
|
|
282
|
-
const left = padding ? toSlidePxX(padding.left, widthEmu) : 0;
|
|
283
|
-
const right = padding ? toSlidePxX(padding.right, widthEmu) : 0;
|
|
284
|
-
const top = padding ? toSlidePxY(padding.top, heightEmu) : 0;
|
|
285
|
-
const bottom = padding
|
|
355
|
+
const left = padding ? toSlidePxX(padding.left, widthEmu, refBox.width) : 0;
|
|
356
|
+
const right = padding ? toSlidePxX(padding.right, widthEmu, refBox.width) : 0;
|
|
357
|
+
const top = padding ? toSlidePxY(padding.top, heightEmu, refBox.height) : 0;
|
|
358
|
+
const bottom = padding
|
|
359
|
+
? toSlidePxY(padding.bottom, heightEmu, refBox.height)
|
|
360
|
+
: 0;
|
|
286
361
|
const align = element.paragraphs?.[0]?.alignment ?? "left";
|
|
287
362
|
const vertical =
|
|
288
363
|
element.verticalAlign === "middle"
|
|
@@ -297,37 +372,55 @@ function buildFidelityParagraph(
|
|
|
297
372
|
paragraph: ParsedParagraph,
|
|
298
373
|
paragraphIndex: number,
|
|
299
374
|
widthEmu: number,
|
|
375
|
+
refWidthPx: number,
|
|
300
376
|
themeFont: string | undefined,
|
|
301
377
|
defaultFontWeight: number,
|
|
302
378
|
): string {
|
|
303
379
|
const firstRun = paragraph.runs[0];
|
|
304
|
-
const fontSize = (firstRun?.fontSize ?? 18
|
|
380
|
+
const fontSize = ptToSlidePx(firstRun?.fontSize ?? 18, widthEmu, refWidthPx);
|
|
305
381
|
const lineHeight = paragraph.lineSpacing ?? 1.2;
|
|
382
|
+
const bulletFontSize = ptToSlidePx(
|
|
383
|
+
paragraph.bulletSize ?? firstRun?.fontSize ?? 18,
|
|
384
|
+
widthEmu,
|
|
385
|
+
refWidthPx,
|
|
386
|
+
);
|
|
306
387
|
const bullet = paragraph.bulletChar
|
|
307
|
-
? `<span aria-hidden="true" style="display:inline-block;width:${fontSize * 0.75}px;min-width:${fontSize * 0.75}px;margin-right:${fontSize * 0.65}px;color:${esc(paragraph.bulletColor ?? firstRun?.color ?? DEFAULT_PPTX_FOREGROUND)};font-family:${cssFontFamily(paragraph.bulletFontFamily ?? themeFont)};font-size:${
|
|
388
|
+
? `<span aria-hidden="true" style="display:inline-block;width:${fontSize * 0.75}px;min-width:${fontSize * 0.75}px;margin-right:${fontSize * 0.65}px;color:${esc(paragraph.bulletColor ?? firstRun?.color ?? DEFAULT_PPTX_FOREGROUND)};font-family:${cssFontFamily(paragraph.bulletFontFamily ?? themeFont)};font-size:${bulletFontSize}px;">${esc(paragraph.bulletChar)}</span>`
|
|
308
389
|
: "";
|
|
309
390
|
const marginLeft = paragraph.marginLeftEmu
|
|
310
|
-
? toSlidePxX(paragraph.marginLeftEmu, widthEmu)
|
|
391
|
+
? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx)
|
|
311
392
|
: 0;
|
|
312
393
|
const indent = paragraph.indentEmu
|
|
313
|
-
? toSlidePxX(paragraph.indentEmu, widthEmu)
|
|
394
|
+
? toSlidePxX(paragraph.indentEmu, widthEmu, refWidthPx)
|
|
314
395
|
: 0;
|
|
315
396
|
const spacingBefore = paragraph.spaceBeforePt ?? 0;
|
|
316
397
|
const spacingAfter = paragraph.spaceAfterPt ?? 0;
|
|
317
398
|
const bulletMargin = paragraph.bulletChar ? `margin-left:${indent}px;` : "";
|
|
399
|
+
const marginBefore = ptToSlidePx(spacingBefore, widthEmu, refWidthPx);
|
|
400
|
+
const marginAfter = ptToSlidePx(spacingAfter, widthEmu, refWidthPx);
|
|
318
401
|
const text = paragraph.runs
|
|
319
|
-
.map((run) =>
|
|
402
|
+
.map((run) =>
|
|
403
|
+
formatFidelityRun(
|
|
404
|
+
run,
|
|
405
|
+
widthEmu,
|
|
406
|
+
refWidthPx,
|
|
407
|
+
themeFont,
|
|
408
|
+
defaultFontWeight,
|
|
409
|
+
),
|
|
410
|
+
)
|
|
320
411
|
.join("");
|
|
321
|
-
return `<p data-pptx-paragraph="${paragraphIndex}" style="display:block;flex:0 0 auto;text-align:${paragraph.alignment ?? "left"};white-space:pre-wrap;margin:${
|
|
412
|
+
return `<p data-pptx-paragraph="${paragraphIndex}" style="display:block;flex:0 0 auto;text-align:${paragraph.alignment ?? "left"};white-space:pre-wrap;margin:${marginBefore}px 0 ${marginAfter}px;line-height:${lineHeight};font-size:${fontSize}px;min-height:${fontSize * lineHeight}px;padding-left:${marginLeft}px;text-indent:${paragraph.bulletChar ? 0 : indent}px;">${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}</p>`;
|
|
322
413
|
}
|
|
323
414
|
|
|
324
415
|
function formatFidelityRun(
|
|
325
416
|
run: ParsedTextRun,
|
|
417
|
+
widthEmu: number,
|
|
418
|
+
refWidthPx: number,
|
|
326
419
|
themeFont: string | undefined,
|
|
327
420
|
defaultFontWeight = 400,
|
|
328
421
|
): string {
|
|
329
422
|
const styles = [
|
|
330
|
-
`font-size:${(run.fontSize ?? 18
|
|
423
|
+
`font-size:${ptToSlidePx(run.fontSize ?? 18, widthEmu, refWidthPx)}px`,
|
|
331
424
|
`font-family:${cssFontFamily(run.fontFamily ?? themeFont)}`,
|
|
332
425
|
`color:${esc(run.color ?? DEFAULT_PPTX_FOREGROUND)}`,
|
|
333
426
|
`font-weight:${run.bold ? 700 : fontWeightForFamily(run.fontFamily, defaultFontWeight)}`,
|
|
@@ -380,14 +380,23 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
|
|
|
380
380
|
prev.italic === item.italic &&
|
|
381
381
|
prev.underline === item.underline &&
|
|
382
382
|
prev.href === item.href;
|
|
383
|
+
const needsSpace =
|
|
384
|
+
prev !== undefined && item.left - prev.right > item.fontSize * 0.25;
|
|
383
385
|
if (prev && sameStyle) {
|
|
384
|
-
const needsSpace = item.left - prev.right > item.fontSize * 0.25;
|
|
385
386
|
prev.text += (needsSpace ? " " : "") + item.text;
|
|
386
387
|
prev.right = Math.max(prev.right, item.right);
|
|
387
388
|
prev.top = Math.min(prev.top, item.top);
|
|
388
389
|
prev.bottom = Math.max(prev.bottom, item.bottom);
|
|
389
390
|
} else {
|
|
390
|
-
|
|
391
|
+
// A word-sized gap has to survive a style change too. Only the
|
|
392
|
+
// same-style branch used to re-add it, so a heading whose colour
|
|
393
|
+
// changed mid-line ("7 Air " + "purifying") lost the space at the
|
|
394
|
+
// boundary and rendered as one jammed-together word.
|
|
395
|
+
const separator =
|
|
396
|
+
needsSpace && !/\s$/.test(prev?.text ?? "") && !/^\s/.test(item.text)
|
|
397
|
+
? " "
|
|
398
|
+
: "";
|
|
399
|
+
runs.push({ ...item, text: separator + item.text });
|
|
391
400
|
}
|
|
392
401
|
}
|
|
393
402
|
return runs;
|