-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathrenderImage.ts
More file actions
302 lines (275 loc) · 10.3 KB
/
Copy pathrenderImage.ts
File metadata and controls
302 lines (275 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
/**
* Image Renderer
*
* Renders image fragments to DOM. Handles:
* - Inline images
* - Anchored/floating images with z-index layering
* - Basic image sizing
*/
import type { ImageFragment, ImageBlock, ImageMeasure } from "../layout-engine/types";
import { sanitizeExternalUrl } from "../utils/urlSecurity";
import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc";
import type { RenderContext } from "./renderUtils";
/**
* CSS class names for image elements
*/
export const IMAGE_CLASS_NAMES = {
image: "layout-image",
imageAnchored: "layout-image-anchored",
};
// eigenpal #424: shared visual-attrs helper used by the inline
// (`renderParagraph`), block, and floating (`renderPage`,
// `renderImageFragment`) image render sites. Currently honours
// `wp:srcRect` crop (image-crop subset) and `a:alphaModFix` opacity
// (opacity render pipeline).
/**
* Structural shape required to apply OOXML per-image visual attributes:
* `wp:srcRect` crop fractions and `a:alphaModFix` opacity. `ImageRun` and
* `ImageBlock` both satisfy this, so callers don't need an adapter.
*/
export type ImageVisualAttrs = {
opacity?: number;
cropTop?: number;
cropRight?: number;
cropBottom?: number;
cropLeft?: number;
};
export type ImageBorderAttrs = {
borderWidth?: number;
borderColor?: string;
borderStyle?: string;
};
/**
* Paint image borders carried through the layout model. Folio's PM schema
* uses `borderStyle` (not upstream's `borderKind`); the painter mirrors the
* editor DOM serialization while keeping the authored image box size stable.
*/
export function applyImageBorder(element: HTMLElement, border: ImageBorderAttrs): void {
if (border.borderWidth == null || border.borderWidth <= 0) {
return;
}
const borderStyle = border.borderStyle || "solid";
const borderColor = border.borderColor || "#000000";
element.style.border = `${border.borderWidth}px ${borderStyle} ${borderColor}`;
element.style.boxSizing = "border-box";
}
/**
* True when any visual attribute is set. Cheap call-site guard so the no-op
* common case skips the helper call.
*
* IMPORTANT: ProseMirror schema attrs default to `null`, not `undefined`,
* and a `null` survives `as number | undefined` casts in the layout bridge.
* Use `!= null` rather than `!== undefined` so default-null opacity / crop
* fields are not read as `0` (`null < 1` is `true`, `Math.max(0, null)` is
* `0`) — that bug otherwise hides every plain image behind `opacity: 0`.
*/
export function hasImageVisualAttrs(v: ImageVisualAttrs): boolean {
if (v.opacity != null && v.opacity < 1) {
return true;
}
return hasImageCrop(v);
}
export function hasImageCrop(v: ImageVisualAttrs): boolean {
return Boolean(v.cropTop || v.cropRight || v.cropBottom || v.cropLeft);
}
/**
* Apply an OOXML `<a:srcRect>` crop to an `<img>` whose parent already has
* `overflow: hidden` and is sized to the *visible* (cropped) dimensions
* (this matches `wp:extent` semantics: the extent is the cropped
* frame, with `<a:stretch><a:fillRect/>` stretching the cropped source to
* fill it).
*
* Implementation: scale the `<img>` up so the visible region fills the
* parent, then shift it by the negative crop offsets so only that region
* remains in view. A naive `clip-path: inset(...)` is wrong here — it would
* leave the bitmap rendered at full extent size (so the wrong region shows)
* and only mask the rest, producing a squished image with blank bands.
*
* `fw = 1/(1-left-right)` and `fh = 1/(1-top-bottom)` are the inverse
* remaining-fractions; multiplying by 100% gives the upscaled width/height
* so the cropped slice exactly covers the parent.
*
* Caller should gate with `hasImageVisualAttrs(v)` to avoid the function
* call for plain images.
*/
export function applyImageVisualAttrs(img: HTMLImageElement, v: ImageVisualAttrs): void {
if (v.opacity != null && v.opacity < 1) {
img.style.opacity = String(Math.max(0, v.opacity));
}
const top = v.cropTop ?? 0;
const right = v.cropRight ?? 0;
const bottom = v.cropBottom ?? 0;
const left = v.cropLeft ?? 0;
if (!(top || right || bottom || left)) {
return;
}
const remainingW = 1 - left - right;
const remainingH = 1 - top - bottom;
// Guard against pathological crops that leave nothing visible; fall back
// to the original sizing rather than dividing by zero.
if (remainingW <= 0 || remainingH <= 0) {
return;
}
const fw = 1 / remainingW;
const fh = 1 / remainingH;
img.style.width = `${fw * 100}%`;
img.style.height = `${fh * 100}%`;
// Cropping deliberately enlarges the bitmap beyond its visible frame.
// Opt out of host image resets that cap replaced elements to their parent.
img.style.maxWidth = "none";
img.style.maxHeight = "none";
img.style.marginLeft = `${-left * fw * 100}%`;
const existingTransform = img.style.transform;
if (existingTransform) {
// The enlarged bitmap's center differs from the visible crop frame's
// center when opposite crop amounts are asymmetric. Rotate or flip around
// the visible center so the cropped region stays aligned with its frame.
const visibleCenterX = (left + 1 - right) * 50;
const visibleCenterY = (top + 1 - bottom) * 50;
img.style.transformOrigin = `${visibleCenterX}% ${visibleCenterY}%`;
}
if (top !== 0) {
// Vertical percentage margins resolve against the containing block's
// width, so they over-shift wide, shallow images. A percentage translate
// resolves against the enlarged bitmap itself: `top * fullHeight` is the
// exact source offset that must move above the clipped frame. Append the
// translation so source cropping occurs before any image flip or rotation.
const cropTransform = `translateY(${-top * 100}%)`;
img.style.transform = existingTransform
? `${existingTransform} ${cropTransform}`
: cropTransform;
}
// Object-fit on the upscaled `<img>` would re-letterbox inside the
// enlarged box; force fill so the bitmap stretches to fw×fh.
img.style.objectFit = "fill";
}
/**
* Create an inline-block `<span>` wrapper sized to the visible (cropped)
* dimensions with `overflow: hidden`, then size+scale+shift the `<img>`
* inside it so only the cropped region of the bitmap is visible. See
* `applyImageVisualAttrs` for the geometry rationale.
*
* Use this for inline runs and block images where the painter does not
* already provide an overflow-clipped container.
*/
export function wrapImageWithCrop(
img: HTMLImageElement,
v: ImageVisualAttrs,
doc: Document,
outerStyle: {
display: "inline-block" | "block";
widthPx: number;
heightPx: number;
},
): HTMLElement {
const wrapper = doc.createElement("span");
wrapper.style.display = outerStyle.display;
wrapper.style.overflow = "hidden";
wrapper.style.width = `${outerStyle.widthPx}px`;
wrapper.style.height = `${outerStyle.heightPx}px`;
// The inner `<img>` is scaled relative to the wrapper, so swap its pixel
// sizing for percentages and let applyImageVisualAttrs do the math.
img.style.width = "100%";
img.style.height = "100%";
applyImageVisualAttrs(img, v);
wrapper.append(img);
return wrapper;
}
/**
* Options for rendering an image fragment
*/
export type RenderImageFragmentOptions = {
document?: Document;
};
/**
* Render an image fragment to DOM
*
* @param fragment - The image fragment to render
* @param block - The full image block
* @param measure - The image measure
* @param context - Rendering context
* @param options - Rendering options
* @returns The image DOM element
*/
export function renderImageFragment(
fragment: ImageFragment,
block: ImageBlock,
_measure: ImageMeasure,
_context: RenderContext,
options: RenderImageFragmentOptions = {},
): HTMLElement {
const doc = options.document ?? document;
// Create container div
const containerEl = doc.createElement("div");
containerEl.className = IMAGE_CLASS_NAMES.image;
if (fragment.isAnchored) {
containerEl.classList.add(IMAGE_CLASS_NAMES.imageAnchored);
}
// Basic styling
containerEl.style.position = "absolute";
containerEl.style.width = `${fragment.width}px`;
containerEl.style.height = `${fragment.height}px`;
containerEl.style.overflow = "hidden";
// Z-index for layering
if (fragment.zIndex !== undefined) {
containerEl.style.zIndex = String(fragment.zIndex);
}
// Behind document flag
if (block.anchor?.behindDoc) {
containerEl.style.zIndex = "-1";
}
// Store metadata
containerEl.dataset["blockId"] = String(fragment.blockId);
if (fragment.pmStart !== undefined) {
containerEl.dataset["pmStart"] = String(fragment.pmStart);
}
if (fragment.pmEnd !== undefined) {
containerEl.dataset["pmEnd"] = String(fragment.pmEnd);
}
// Create the actual image element. Only local data:/blob: sources are
// painted — remote or executable schemes are dropped (src left unset).
const imgEl = doc.createElement("img");
applySanitizedImageSrc(imgEl, block.src);
imgEl.alt = block.alt ?? "";
// Image sizing
imgEl.style.width = "100%";
imgEl.style.height = "100%";
imgEl.style.objectFit = "contain";
imgEl.style.display = "block";
// Apply transform if present (rotation, flip)
if (block.transform) {
imgEl.style.transform = block.transform;
}
// Scale/shift `<img>` so the cropped slice fills the overflow-hidden
// container (already sized to the visible extent above), plus emit
// `opacity` when set via `<a:alphaModFix>`.
if (hasImageVisualAttrs(block)) {
applyImageVisualAttrs(imgEl, block);
}
// Prevent dragging
imgEl.draggable = false;
// Wrap in hyperlink if image has a link
const hlinkHref = sanitizeExternalUrl(block.hlinkHref);
if (hlinkHref) {
const linkEl = doc.createElement("a");
linkEl.href = hlinkHref;
linkEl.target = "_blank";
linkEl.rel = "noopener noreferrer";
linkEl.style.display = "block";
linkEl.style.width = "100%";
linkEl.style.height = "100%";
linkEl.append(imgEl);
containerEl.append(linkEl);
} else {
containerEl.append(imgEl);
}
// Cropped images clip an overflow-hidden container around a scaled `<img>`,
// so a border on the `<img>` itself is invisible. Paint on the container
// instead; uncropped images keep the border on the `<img>`.
if (hasImageCrop(block)) {
applyImageBorder(containerEl, block);
} else {
applyImageBorder(imgEl, block);
}
return containerEl;
}