svelte-rtf-editor 1.5.0 → 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,9 +399,9 @@ export function rtfToHtml(rtfString) {
444
399
  }
445
400
  }
446
401
  }
447
- // Sizes are shown relative to the body: body text carries no size of its
448
- // own, and headings are recognized by their ratio to it.
449
- 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;
450
405
  // State stack for nested groups
451
406
  function defaultState() {
452
407
  return {
@@ -471,7 +426,7 @@ export function rtfToHtml(rtfString) {
471
426
  function cloneState() {
472
427
  return { ...currentState() };
473
428
  }
474
- function buildSpanOpen(state) {
429
+ function buildSpanOpen(state, withSize = true) {
475
430
  const styles = [];
476
431
  if (state.colorIndex > 0 && state.colorIndex < colorTable.length) {
477
432
  const color = colorTable[state.colorIndex];
@@ -484,7 +439,7 @@ export function rtfToHtml(rtfString) {
484
439
  if (bg)
485
440
  styles.push(`background-color:${bg}`);
486
441
  }
487
- if (state.fontSize && state.fontSize !== bodySize) {
442
+ if (withSize && state.fontSize && state.fontSize !== bodySize) {
488
443
  styles.push(`font-size:${state.fontSize / 2}pt`);
489
444
  }
490
445
  if (styles.length === 0)
@@ -494,12 +449,16 @@ export function rtfToHtml(rtfString) {
494
449
  function wrapText(text, state) {
495
450
  if (!text)
496
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);
497
456
  let result = text
498
457
  .replace(/&/g, '&amp;')
499
458
  .replace(/</g, '&lt;')
500
459
  .replace(/>/g, '&gt;');
501
460
  // Apply span styles
502
- const spanOpen = buildSpanOpen(state);
461
+ const spanOpen = buildSpanOpen(state, !onlySpaces);
503
462
  if (spanOpen) {
504
463
  result = `${spanOpen}${result}</span>`;
505
464
  }
@@ -888,15 +847,19 @@ export function rtfToHtml(rtfString) {
888
847
  const halfPoints = Math.round(parseFloat(sizeMatch[1]) * 2);
889
848
  const alignAttr = align !== 'left' ? ` style="text-align:${align}"` : '';
890
849
  const atLeast = (scale) => halfPoints >= Math.round(bodySize * scale);
891
- 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)) {
892
855
  rendered.push(`<h1${alignAttr}>${stripSizeSpan(trimmed)}</h1>`);
893
856
  continue;
894
857
  }
895
- if (atLeast(HEADING_SCALE.h2)) {
858
+ if (looksLikeHeading && atLeast(HEADING_SCALE.h2)) {
896
859
  rendered.push(`<h2${alignAttr}>${stripSizeSpan(trimmed)}</h2>`);
897
860
  continue;
898
861
  }
899
- if (atLeast(HEADING_SCALE.h3)) {
862
+ if (looksLikeHeading && atLeast(HEADING_SCALE.h3)) {
900
863
  rendered.push(`<h3${alignAttr}>${stripSizeSpan(trimmed)}</h3>`);
901
864
  continue;
902
865
  }
@@ -916,13 +879,13 @@ function stripSizeSpan(html) {
916
879
  function stripWrappingEm(html) {
917
880
  return html.replace(/^<em>([\s\S]*)<\/em>$/, '$1');
918
881
  }
919
- export function readRtfFile(file) {
882
+ export function readRtfFile(file, options = {}) {
920
883
  return new Promise((resolve, reject) => {
921
884
  const reader = new FileReader();
922
885
  reader.onload = () => {
923
886
  try {
924
887
  const text = reader.result;
925
- const html = rtfToHtml(text);
888
+ const html = rtfToHtml(text, options);
926
889
  resolve(html);
927
890
  }
928
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.0",
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",