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 CHANGED
@@ -169,7 +169,7 @@ import {
169
169
 
170
170
  | Function | Description |
171
171
  |---------------------------------------|----------------------------------------------------|
172
- | `rtfToHtml(rtf: string): string` | Parse an RTF string and return an HTML 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
- - `rtfToHtml` recognizes headings relative to the document's own body size, so a document written at any size reads back with the same headings.
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
 
@@ -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
 
@@ -694,7 +694,7 @@
694
694
  importing = true;
695
695
 
696
696
  try {
697
- const html = await readRtfFile(file);
697
+ const html = await readRtfFile(file, { fontSize });
698
698
  if (editorEl) {
699
699
  selectedImage = null;
700
700
  editorEl.innerHTML = html;
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';
@@ -10,5 +10,16 @@
10
10
  *
11
11
  * \cb (character background) is silently ignored on import.
12
12
  */
13
- export declare function rtfToHtml(rtfString: string): string;
14
- export declare function readRtfFile(file: File): Promise<string>;
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>;
@@ -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
- /** Default RTF font size: 12 pt, in half-points. */
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
- // Headings are recognized by their ratio to the body size. Text keeps its
448
- // own size, though: anything other than 12 pt carries a font-size, so a
449
- // document written at 9 pt still shows at 9 pt.
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 !== DEFAULT_HALF_POINTS) {
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, '&amp;')
500
458
  .replace(/</g, '&lt;')
501
459
  .replace(/>/g, '&gt;');
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
- if (atLeast(HEADING_SCALE.h1)) {
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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-rtf-editor",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "RTF viewer and rich-text editor components for Svelte 5",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/jcelano/svelte-rtf-editor#readme",