svelte-rtf-editor 1.5.1 → 1.5.2
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/README.md +2 -2
- package/dist/RtfViewer.svelte +1 -1
- package/dist/components/InkEditor.svelte +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/rtf-parser.d.ts +13 -2
- package/dist/rtf-parser.js +21 -59
- package/dist/rtf-writer.js +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ import {
|
|
|
169
169
|
|
|
170
170
|
| Function | Description |
|
|
171
171
|
|---------------------------------------|----------------------------------------------------|
|
|
172
|
-
| `rtfToHtml(rtf: string): string`
|
|
172
|
+
| `rtfToHtml(rtf: string, options?: { fontSize? }): string` | Parse an RTF string and return an HTML string. `fontSize` is the size of the screen it will be shown on (default 12) |
|
|
173
173
|
| `htmlToRtf(el: HTMLElement, options?: { fontFamily?, fontSize? }): string` | Convert a DOM element's content to RTF, in Arial 12 pt unless told otherwise |
|
|
174
174
|
| `estimateRtfBytes(el: HTMLElement): number` | Approximate RTF size of a DOM element without converting it |
|
|
175
175
|
| `readRtfFile(file: File): Promise<string>` | Read a `.rtf` File object and return HTML |
|
|
@@ -195,7 +195,7 @@ htmlToRtf(el, { fontFamily: 'Calibri', fontSize: 11 });
|
|
|
195
195
|
- If you call `htmlToRtf` yourself on the editor's content, pass the same options you gave the editor, or the two produce different RTF.
|
|
196
196
|
- `fontFamily` names one font. Given a CSS list such as `'Arial, sans-serif'`, only the first family is used.
|
|
197
197
|
- `fontSize` is rounded to the nearest half-point, which is what RTF measures in, and kept between 1 and 1638 pt.
|
|
198
|
-
- `
|
|
198
|
+
- If you show RTF in an editor or viewer set to another size, pass the same `fontSize` to `rtfToHtml`. Text at that size then carries no size of its own, text at any other size keeps it, and headings are recognized by the same ratios the writer uses. `InkEditor`'s RTF import and `RtfViewer` do this themselves. At another size than 12 pt, a heading must also be bold, as every heading the writer emits is.
|
|
199
199
|
|
|
200
200
|
The font has to be installed on the machine that displays the document. The RTF names it, and a reader without it substitutes one of its own.
|
|
201
201
|
|
package/dist/RtfViewer.svelte
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
const screenFontFamily = $derived(cssFontFamily(fontFamily));
|
|
20
20
|
const screenFontSize = $derived(normalizeFontSize(fontSize));
|
|
21
21
|
|
|
22
|
-
const htmlContent = $derived(rtfToHtml(content));
|
|
22
|
+
const htmlContent = $derived(rtfToHtml(content, { fontSize }));
|
|
23
23
|
|
|
24
24
|
let copied = $state(false);
|
|
25
25
|
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ export { default as InkEditor } from './components/InkEditor.svelte';
|
|
|
3
3
|
export { rtfToHtml, readRtfFile } from './rtf-parser.js';
|
|
4
4
|
export { htmlToRtf, estimateRtfBytes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from './rtf-writer.js';
|
|
5
5
|
export type { RtfWriteOptions } from './rtf-writer.js';
|
|
6
|
+
export type { RtfReadOptions } from './rtf-parser.js';
|
|
6
7
|
export { htmlToMarkdown, downloadFile } from './utils.js';
|
package/dist/rtf-parser.d.ts
CHANGED
|
@@ -10,5 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* \cb (character background) is silently ignored on import.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
export
|
|
13
|
+
/** Options for rtfToHtml. */
|
|
14
|
+
export interface RtfReadOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The body text size of the screen the HTML will be shown on, in points:
|
|
17
|
+
* the editor's or viewer's fontSize. Text at this size carries no size of
|
|
18
|
+
* its own, text at any other size keeps its font-size, and headings are
|
|
19
|
+
* recognized by their ratio to it (HEADING_SCALE). Default: 12, which reads
|
|
20
|
+
* a document exactly as 1.4.0 did.
|
|
21
|
+
*/
|
|
22
|
+
fontSize?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function rtfToHtml(rtfString: string, options?: RtfReadOptions): string;
|
|
25
|
+
export declare function readRtfFile(file: File, options?: RtfReadOptions): Promise<string>;
|
package/dist/rtf-parser.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* \cb (character background) is silently ignored on import.
|
|
12
12
|
*/
|
|
13
|
-
import { HEADING_SCALE } from './rtf-writer.js';
|
|
13
|
+
import { HEADING_SCALE, normalizeFontSize } from './rtf-writer.js';
|
|
14
14
|
// ── Highlight palette (RTF \highlight 1-16) ──
|
|
15
15
|
const HIGHLIGHT_COLORS = {
|
|
16
16
|
1: '#000000', // Black
|
|
@@ -357,52 +357,7 @@ const destinationWords = new Set([
|
|
|
357
357
|
'latentstyles', 'datastore', 'mmathPr', 'author', 'operator',
|
|
358
358
|
'title', 'subject', 'doccomm', 'company', 'category', 'keywords'
|
|
359
359
|
]);
|
|
360
|
-
|
|
361
|
-
const DEFAULT_HALF_POINTS = 24;
|
|
362
|
-
/**
|
|
363
|
-
* The document's body text size, in half-points: the size most of its text is
|
|
364
|
-
* set in. Headings are recognized relative to it, so a document written at
|
|
365
|
-
* 10 or 14 pt keeps its paragraphs and headings apart instead of being judged
|
|
366
|
-
* against a fixed 12 pt.
|
|
367
|
-
*/
|
|
368
|
-
function bodyHalfPoints(root) {
|
|
369
|
-
const chars = new Map();
|
|
370
|
-
function visit(nodes, size) {
|
|
371
|
-
for (const node of nodes) {
|
|
372
|
-
if (node.type === 'group') {
|
|
373
|
-
const first = node.children.find((n) => n.type === 'control');
|
|
374
|
-
const starred = node.children[0]?.type === 'control' && node.children[0].word === '*';
|
|
375
|
-
if (starred || (first && (first.word === 'pict' || destinationWords.has(first.word))))
|
|
376
|
-
continue;
|
|
377
|
-
visit(node.children, size);
|
|
378
|
-
}
|
|
379
|
-
else if (node.type === 'control') {
|
|
380
|
-
if (node.word === 'fs' && node.param != null && node.param > 0)
|
|
381
|
-
size = node.param;
|
|
382
|
-
else if (node.word === 'plain')
|
|
383
|
-
size = DEFAULT_HALF_POINTS;
|
|
384
|
-
}
|
|
385
|
-
else {
|
|
386
|
-
const n = node.value.trim().length;
|
|
387
|
-
if (n)
|
|
388
|
-
chars.set(size, (chars.get(size) ?? 0) + n);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return size;
|
|
392
|
-
}
|
|
393
|
-
visit(root.children, DEFAULT_HALF_POINTS);
|
|
394
|
-
let best = DEFAULT_HALF_POINTS;
|
|
395
|
-
let most = 0;
|
|
396
|
-
for (const [size, n] of chars) {
|
|
397
|
-
if (n > most) {
|
|
398
|
-
best = size;
|
|
399
|
-
most = n;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return best;
|
|
403
|
-
}
|
|
404
|
-
// ── Renderer ──
|
|
405
|
-
export function rtfToHtml(rtfString) {
|
|
360
|
+
export function rtfToHtml(rtfString, options = {}) {
|
|
406
361
|
const tokens = tokenize(rtfString);
|
|
407
362
|
const tree = buildTree(tokens);
|
|
408
363
|
let colorTable = [''];
|
|
@@ -444,10 +399,9 @@ export function rtfToHtml(rtfString) {
|
|
|
444
399
|
}
|
|
445
400
|
}
|
|
446
401
|
}
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
const bodySize = bodyHalfPoints(rootGroup);
|
|
402
|
+
// The screen's body size, in half-points. It is given, not guessed from the
|
|
403
|
+
// document: guessing misread short documents and ones set below 12 pt.
|
|
404
|
+
const bodySize = normalizeFontSize(options.fontSize) * 2;
|
|
451
405
|
// State stack for nested groups
|
|
452
406
|
function defaultState() {
|
|
453
407
|
return {
|
|
@@ -472,7 +426,7 @@ export function rtfToHtml(rtfString) {
|
|
|
472
426
|
function cloneState() {
|
|
473
427
|
return { ...currentState() };
|
|
474
428
|
}
|
|
475
|
-
function buildSpanOpen(state) {
|
|
429
|
+
function buildSpanOpen(state, withSize = true) {
|
|
476
430
|
const styles = [];
|
|
477
431
|
if (state.colorIndex > 0 && state.colorIndex < colorTable.length) {
|
|
478
432
|
const color = colorTable[state.colorIndex];
|
|
@@ -485,7 +439,7 @@ export function rtfToHtml(rtfString) {
|
|
|
485
439
|
if (bg)
|
|
486
440
|
styles.push(`background-color:${bg}`);
|
|
487
441
|
}
|
|
488
|
-
if (state.fontSize && state.fontSize !==
|
|
442
|
+
if (withSize && state.fontSize && state.fontSize !== bodySize) {
|
|
489
443
|
styles.push(`font-size:${state.fontSize / 2}pt`);
|
|
490
444
|
}
|
|
491
445
|
if (styles.length === 0)
|
|
@@ -495,12 +449,16 @@ export function rtfToHtml(rtfString) {
|
|
|
495
449
|
function wrapText(text, state) {
|
|
496
450
|
if (!text)
|
|
497
451
|
return '';
|
|
452
|
+
// A size on spaces alone is not shown and would be read as the
|
|
453
|
+
// paragraph's size, so it is dropped. Underline, strikethrough and
|
|
454
|
+
// highlight are kept: an underlined blank is a signature line.
|
|
455
|
+
const onlySpaces = /^[ \t\r\n\f]*$/.test(text);
|
|
498
456
|
let result = text
|
|
499
457
|
.replace(/&/g, '&')
|
|
500
458
|
.replace(/</g, '<')
|
|
501
459
|
.replace(/>/g, '>');
|
|
502
460
|
// Apply span styles
|
|
503
|
-
const spanOpen = buildSpanOpen(state);
|
|
461
|
+
const spanOpen = buildSpanOpen(state, !onlySpaces);
|
|
504
462
|
if (spanOpen) {
|
|
505
463
|
result = `${spanOpen}${result}</span>`;
|
|
506
464
|
}
|
|
@@ -889,15 +847,19 @@ export function rtfToHtml(rtfString) {
|
|
|
889
847
|
const halfPoints = Math.round(parseFloat(sizeMatch[1]) * 2);
|
|
890
848
|
const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
|
|
891
849
|
const atLeast = (scale) => halfPoints >= Math.round(bodySize * scale);
|
|
892
|
-
|
|
850
|
+
// At the default 12 pt, size alone makes a heading, as it always has. At
|
|
851
|
+
// another screen size, ordinary 12 pt text can be a step above the body,
|
|
852
|
+
// so a heading must also be bold, as every heading the writer emits is.
|
|
853
|
+
const looksLikeHeading = bodySize === 24 || /^<strong>/.test(trimmed);
|
|
854
|
+
if (looksLikeHeading && atLeast(HEADING_SCALE.h1)) {
|
|
893
855
|
rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
|
|
894
856
|
continue;
|
|
895
857
|
}
|
|
896
|
-
if (atLeast(HEADING_SCALE.h2)) {
|
|
858
|
+
if (looksLikeHeading && atLeast(HEADING_SCALE.h2)) {
|
|
897
859
|
rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
|
|
898
860
|
continue;
|
|
899
861
|
}
|
|
900
|
-
if (atLeast(HEADING_SCALE.h3)) {
|
|
862
|
+
if (looksLikeHeading && atLeast(HEADING_SCALE.h3)) {
|
|
901
863
|
rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
|
|
902
864
|
continue;
|
|
903
865
|
}
|
|
@@ -917,13 +879,13 @@ function stripSizeSpan(html) {
|
|
|
917
879
|
function stripWrappingEm(html) {
|
|
918
880
|
return html.replace(/^<em>([\s\S]*)<\/em>$/, '$1');
|
|
919
881
|
}
|
|
920
|
-
export function readRtfFile(file) {
|
|
882
|
+
export function readRtfFile(file, options = {}) {
|
|
921
883
|
return new Promise((resolve, reject) => {
|
|
922
884
|
const reader = new FileReader();
|
|
923
885
|
reader.onload = () => {
|
|
924
886
|
try {
|
|
925
887
|
const text = reader.result;
|
|
926
|
-
const html = rtfToHtml(text);
|
|
888
|
+
const html = rtfToHtml(text, options);
|
|
927
889
|
resolve(html);
|
|
928
890
|
}
|
|
929
891
|
catch (err) {
|
package/dist/rtf-writer.js
CHANGED
|
@@ -448,6 +448,11 @@ function walkChildren(parent, ctx) {
|
|
|
448
448
|
const node = children[i];
|
|
449
449
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
450
450
|
const text = node.textContent || '';
|
|
451
|
+
// Whitespace between two blocks (the newline in "</p>\n<p>") shows as
|
|
452
|
+
// nothing in a browser. Written out, it became a space at the start of
|
|
453
|
+
// the next paragraph.
|
|
454
|
+
if (!ctx.inPre && COLLAPSIBLE_WHITESPACE.test(text) && besideBlock(node))
|
|
455
|
+
continue;
|
|
451
456
|
if (ctx.inPre) {
|
|
452
457
|
// Split before escaping — escapeRtf collapses newlines, so the
|
|
453
458
|
// line structure a <pre> block depends on has to become \line
|
|
@@ -655,6 +660,24 @@ function walkChildren(parent, ctx) {
|
|
|
655
660
|
function scaled(ctx, ratio) {
|
|
656
661
|
return Math.round(ctx.bodySize * ratio);
|
|
657
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* Whitespace a browser collapses: spaces, tabs and line breaks, but not a
|
|
665
|
+
* non-breaking space, which is there on purpose.
|
|
666
|
+
*/
|
|
667
|
+
const COLLAPSIBLE_WHITESPACE = /^[ \t\r\n\f]*$/;
|
|
668
|
+
const BLOCK_TAGS = new Set([
|
|
669
|
+
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'table', 'thead',
|
|
670
|
+
'tbody', 'tfoot', 'tr', 'td', 'th', 'blockquote', 'pre', 'hr', 'figure', 'figcaption'
|
|
671
|
+
]);
|
|
672
|
+
function isBlock(node) {
|
|
673
|
+
return node?.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName.toLowerCase());
|
|
674
|
+
}
|
|
675
|
+
/** A text node with a block element on either side, or at the edge of one. */
|
|
676
|
+
function besideBlock(node) {
|
|
677
|
+
const prev = node.previousSibling;
|
|
678
|
+
const next = node.nextSibling;
|
|
679
|
+
return isBlock(prev) || isBlock(next) || (!prev && !next && isBlock(node.parentNode));
|
|
680
|
+
}
|
|
658
681
|
function getColorPrefix(el, ctx) {
|
|
659
682
|
// Support both inline style.color (spans) and the color attribute (<font>)
|
|
660
683
|
const color = el.style?.color ||
|
package/package.json
CHANGED