Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/super-editor/src/editors/v1/core/helpers/base64.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** Latin-1 / "binary" string -> base64 (browser `btoa`, else Node `Buffer`). */
function binaryStringToBase64(binary) {
if (typeof globalThis.btoa === 'function') {
return globalThis.btoa(binary);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(binary, 'latin1').toString('base64');
}
throw new Error('[base64] encode requires btoa (browser) or Buffer (Node)');
}

/** base64 -> Latin-1 / "binary" string (browser `atob`, else Node `Buffer`). */
function base64ToBinaryString(b64) {
if (typeof globalThis.atob === 'function') {
return globalThis.atob(b64);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(b64, 'base64').toString('latin1');
}
throw new Error('[base64] decode requires atob (browser) or Buffer (Node)');
}

/**
* UTF-8 string -> base64. Same idea as `btoa(unescape(encodeURIComponent(s)))` without `unescape`.
* @param {string} input
*/
export function encodeUtf8Base64(input) {
const binary = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
return binaryStringToBase64(binary);
}

/**
* base64 -> UTF-8 string. Decodes bytes then UTF-8 via percent-encoding.
* @param {string} b64
*/
export function decodeUtf8Base64(b64) {
if (!b64) return '';
try {
const bin = base64ToBinaryString(b64);
let pct = '';
for (let i = 0; i < bin.length; i += 1) {
pct += `%${bin.charCodeAt(i).toString(16).padStart(2, '0')}`;
}
return decodeURIComponent(pct);
} catch {
return '';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* in Node (tests) uses `Buffer` for base64 when `btoa`/`atob` are missing.
*/
import { getSectPrColumns } from '../super-converter/section-properties.js';
import { encodeUtf8Base64, decodeUtf8Base64 } from './base64.js';

export const SUPERDOC_SLICE_MIME = 'application/x-superdoc-slice';
/** JSON map of package-relative image path → display URL (data URL, https, or blob URL). */
Expand Down Expand Up @@ -193,57 +194,6 @@ export function applySuperdocClipboardMedia(editor, clipboardData, sliceJson = n
return outSlice;
}

/** Latin-1 / “binary” string → base64 (browser `btoa`, else Node `Buffer`). */
function binaryStringToBase64(binary) {
if (typeof globalThis.btoa === 'function') {
return globalThis.btoa(binary);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(binary, 'latin1').toString('base64');
}
throw new Error('[superdocClipboardSlice] base64 encode requires btoa (browser) or Buffer (Node)');
}

/** base64 → Latin-1 / “binary” string (browser `atob`, else Node `Buffer`). */
function base64ToBinaryString(b64) {
if (typeof globalThis.atob === 'function') {
return globalThis.atob(b64);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(b64, 'base64').toString('latin1');
}
throw new Error('[superdocClipboardSlice] base64 decode requires atob (browser) or Buffer (Node)');
}

/**
* UTF-8 string → base64. Same idea as `btoa(unescape(encodeURIComponent(s)))` without `unescape`.
* @param {string} input
*/
function encodeUtf8Base64(input) {
const binary = encodeURIComponent(input).replace(/%([0-9A-F]{2})/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
return binaryStringToBase64(binary);
}

/**
* base64 → UTF-8 string. Decodes bytes then UTF-8 via percent-encoding.
* @param {string} b64
*/
function decodeUtf8Base64(b64) {
if (!b64) return '';
try {
const bin = base64ToBinaryString(b64);
let pct = '';
for (let i = 0; i < bin.length; i += 1) {
pct += `%${bin.charCodeAt(i).toString(16).padStart(2, '0')}`;
}
return decodeURIComponent(pct);
} catch {
return '';
}
}

export function bodySectPrShouldEmbed(bodySectPr) {
if (!bodySectPr || typeof bodySectPr !== 'object') return false;
const cols = getSectPrColumns(bodySectPr);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { encodeUtf8Base64 } from '../../../../../../helpers/base64.js';

/**
* Handles VML shape elements with v:textpath (text watermarks).
*
Expand All @@ -17,6 +19,11 @@
* @param {Object} options
* @returns {Object|null}
*/
// Word positions centered, rotated VML WordArt below the shape's geometric center.
const ROTATED_CENTERED_WATERMARK_TOP_OFFSET_RATIO = 0.25;
// Guard against malformed VML height values when calculating the WordArt offset.
const MAX_ROTATED_CENTERED_WATERMARK_OFFSET_HEIGHT_PX = 10000;

export function handleShapeTextWatermarkImport({ pict }) {
const shape = pict.elements?.find((el) => el.name === 'v:shape');
if (!shape) return null;
Expand Down Expand Up @@ -82,12 +89,13 @@ export function handleShapeTextWatermarkImport({ pict }) {
// Extract text formatting from textpath style
const textpathStyle = textpathAttrs.style || '';
const textStyleObj = parseVmlStyle(textpathStyle);
const rawFontFamily = textStyleObj['font-family']?.replace(/['"]/g, '');
const rawFontFamily = decodeXmlEntities(textStyleObj['font-family'] || '').replace(/['"]/g, '');
const fontFamily = sanitizeFontFamily(rawFontFamily);
const fontSize = textStyleObj['font-size'] || '1pt';

// Extract other textpath attributes
const fitshape = textpathAttrs.fitshape || 't';
const shouldFitShape = fitshape === 't';
const trim = textpathAttrs.trim || 't';
const textpathOn = textpathAttrs.on || 't';

Expand All @@ -107,7 +115,7 @@ export function handleShapeTextWatermarkImport({ pict }) {
const heightPx = convertToPixels(height);

// Sanitize numeric values before use
const sanitizedOpacity = sanitizeNumeric(parseFloat(opacity), 0.5, 0, 1);
const sanitizedOpacity = sanitizeNumeric(parseVmlOpacity(opacity), 0.5, 0, 1);
const sanitizedRotation = sanitizeNumeric(rotation, 0, -360, 360);

const svgResult = generateTextWatermarkSVG({
Expand All @@ -123,9 +131,18 @@ export function handleShapeTextWatermarkImport({ pict }) {
fontFamily,
fontSize,
},
fitShape: shouldFitShape,
});

const svgDataUri = svgResult.dataUri;
const centerOffsetTop = getTextWatermarkCenterOffset({
hPosition,
vPosition,
hRelativeTo,
vRelativeTo,
height: heightPx,
rotation: sanitizedRotation,
});

// Return as an image node (so it uses the Image extension for rendering)
// but preserve all VML attributes for export round-trip
Expand Down Expand Up @@ -171,7 +188,7 @@ export function handleShapeTextWatermarkImport({ pict }) {
// For center-aligned watermarks relative to margin, Word's margin values
// are not suitable for browser rendering. Set to 0 to let center alignment work.
horizontal: hPosition === 'center' && hRelativeTo === 'margin' ? 0 : convertToPixels(position.marginLeft),
top: vPosition === 'center' && vRelativeTo === 'margin' ? 0 : convertToPixels(position.marginTop),
top: vPosition === 'center' && vRelativeTo === 'margin' ? centerOffsetTop : convertToPixels(position.marginTop),
},
// Store text watermark specific data for export
textWatermarkData: {
Expand All @@ -196,7 +213,7 @@ export function handleShapeTextWatermarkImport({ pict }) {
},
textpath: {
on: textpathOn === 't',
fitshape: fitshape === 't',
fitshape: shouldFitShape,
trim: trim === 't',
textpathok: textpathok === 't',
},
Expand All @@ -219,7 +236,7 @@ function sanitizeFontFamily(fontFamily) {
}
// Only allow alphanumeric, spaces, hyphens, and commas (for font lists)
// This prevents injection via quotes, angle brackets, parentheses, etc.
const sanitized = fontFamily.replace(/[^a-zA-Z0-9\s,\-]/g, '').trim();
const sanitized = fontFamily.replace(/[^a-zA-Z0-9\s,-]/g, '').trim();
return sanitized || 'Arial';
}

Expand All @@ -243,6 +260,41 @@ function sanitizeColor(color, defaultColor = 'silver') {
return sanitized || defaultColor;
}

function normalizeVmlColor(color) {
const namedColors = {
black: '#000000',
blue: '#0000FF',
gray: '#808080',
green: '#008000',
lime: '#00FF00',
red: '#FF0000',
silver: '#C0C0C0',
white: '#FFFFFF',
yellow: '#FFFF00',
};

const key = typeof color === 'string' ? color.trim().toLowerCase() : '';
return namedColors[key] || color;
}

function decodeXmlEntities(value) {
return value
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&#(\d+);/g, (_, code) => decodeCodePoint(Number(code)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => decodeCodePoint(Number.parseInt(code, 16)));
}

function decodeCodePoint(codePoint) {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return '';
}
return String.fromCodePoint(codePoint);
}

/**
* Validate and sanitize numeric value.
* @param {number|string} value - Numeric value
Expand All @@ -262,21 +314,50 @@ function sanitizeNumeric(value, defaultValue, min = -Infinity, max = Infinity) {
return Math.max(min, Math.min(max, num));
}

function parseVmlOpacity(value) {
if (typeof value === 'number') {
return value;
}
if (!value || typeof value !== 'string') {
return NaN;
}

const normalized = value.trim().toLowerCase();
if (normalized.endsWith('%')) {
return Number.parseFloat(normalized.slice(0, -1)) / 100;
}
if (normalized.endsWith('f')) {
return Number.parseInt(normalized.slice(0, -1), 10) / 65536;
}
return Number.parseFloat(normalized);
}

function getTextWatermarkCenterOffset({ hPosition, vPosition, hRelativeTo, vRelativeTo, height, rotation }) {
const isCenteredMarginWatermark =
hPosition === 'center' && vPosition === 'center' && hRelativeTo === 'margin' && vRelativeTo === 'margin';
if (!isCenteredMarginWatermark || rotation === 0) {
return 0;
}

return (
sanitizeNumeric(height, 0, 0, MAX_ROTATED_CENTERED_WATERMARK_OFFSET_HEIGHT_PX) *
ROTATED_CENTERED_WATERMARK_TOP_OFFSET_RATIO
);
}

/**
* Generate an SVG data URI for a text watermark with rotation.
* Rotation must be baked into the SVG since the layout engine doesn't support
* rotation for image fragments (only drawing fragments).
* @param {Object} options - Watermark options
* @returns {Object} Object with dataUri, svgWidth, and svgHeight
*/
function generateTextWatermarkSVG({ text, width, height, rotation, fill, textStyle }) {
function generateTextWatermarkSVG({ text, width, height, rotation, fill, textStyle, fitShape }) {
// Word watermarks don't use font-size literally - they scale text to fill available space
// Word VML typically specifies font-size:1pt, but this is just a scaling hint
// The actual rendered size depends on the watermark dimensions (width/height)

let fontSize = height * 0.9; // The value of 0.9 was determined by me by visual comparison.
// It seems to be close to correct for text without rotation and slightly too low for text
// with rotation.
let fontSize = height * 1.12;
// Alternative: if explicit font size is given and not the typical 1pt, respect it
// Only override if it's not the typical Word watermark 1pt
if (textStyle?.fontSize && textStyle.fontSize.trim() !== '1pt') {
Expand All @@ -290,9 +371,9 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty
fontSize = Math.max(fontSize, 48); // Minimum visible size

// Sanitize all values from untrusted input
const color = sanitizeColor(fill?.color, 'silver');
const opacity = sanitizeNumeric(fill?.opacity, 0.5, 0, 1);
const fontFamily = sanitizeFontFamily(textStyle?.fontFamily);
const color = normalizeVmlColor(sanitizeColor(fill?.color, 'silver'));
const opacity = resolveRenderedTextWatermarkOpacity(sanitizeNumeric(fill?.opacity, 0.5, 0, 1));
const fontFamily = resolveSvgFontFamily(sanitizeFontFamily(textStyle?.fontFamily));
const sanitizedRotation = sanitizeNumeric(rotation, 0, -360, 360);
const sanitizedWidth = sanitizeNumeric(width, 100, 1, 10000);
const sanitizedHeight = sanitizeNumeric(height, 100, 1, 10000);
Expand All @@ -314,27 +395,51 @@ function generateTextWatermarkSVG({ text, width, height, rotation, fill, textSty
// Center the rotation in the larger SVG canvas
const centerX = svgWidth / 2;
const centerY = svgHeight / 2;
const textAttributes = [
`x="${centerX}"`,
`y="${centerY}"`,
'text-anchor="middle"',
'dominant-baseline="middle"',
`font-family="${fontFamily}"`,
`font-size="${sanitizedFontSize}px"`,
...(fitShape ? [`textLength="${sanitizedWidth}"`, 'lengthAdjust="spacingAndGlyphs"'] : []),
`fill="${color}"`,
`fill-opacity="${opacity}"`,
`transform="rotate(${sanitizedRotation} ${centerX} ${centerY})"`,
]
.map((attribute) => ` ${attribute}`)
.join('\n');

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}" style="overflow: visible;">
<text
x="${centerX}"
y="${centerY}"
text-anchor="middle"
dominant-baseline="middle"
font-family="${fontFamily}"
font-size="${sanitizedFontSize}px"
fill="${color}"
opacity="${opacity}"
transform="rotate(${sanitizedRotation} ${centerX} ${centerY})">${escapeXml(text)}</text>
</svg>`;
<text ${textAttributes}>${escapeXml(text)}</text>
</svg>`;

return {
dataUri: `data:image/svg+xml,${encodeURIComponent(svg)}`,
dataUri: `data:image/svg+xml;base64,${encodeUtf8Base64(svg)}`,
svgWidth,
svgHeight,
};
}

function resolveRenderedTextWatermarkOpacity(opacity) {
return sanitizeNumeric(opacity * 0.5, 0.25, 0, 1);
}

function resolveSvgFontFamily(fontFamily) {
if (!fontFamily || typeof fontFamily !== 'string') {
return 'Arial, sans-serif';
}

const normalized = fontFamily.trim();
if (normalized.includes(',')) {
return normalized;
}

const serifFonts = new Set(['cambria', 'constantia', 'georgia', 'times new roman', 'times']);
const generic = serifFonts.has(normalized.toLowerCase()) ? 'serif' : 'Arial, sans-serif';
return `${normalized}, ${generic}`;
}

/**
* Escape XML special characters.
* @param {string} text
Expand All @@ -358,7 +463,9 @@ function parseVmlStyle(style) {
const result = {};
if (!style) return result;

const declarations = style.split(';').filter((s) => s.trim());
const declarations = decodeXmlEntities(style)
.split(';')
.filter((s) => s.trim());
for (const decl of declarations) {
const colonIndex = decl.indexOf(':');
if (colonIndex === -1) continue;
Expand Down
Loading
Loading