reamkit 1.33.0 → 1.33.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Alex Krassavin
3
+ Copyright (c) 2026 Alexandr Krassavin
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -59,8 +59,9 @@ and `doc.convertWithReport(...)` returns `{ bytes, losses }` (pass
59
59
  are plain `Uint8Array`s, so wiring this to files, the network, or disk is up
60
60
  to you.
61
61
 
62
- An encrypted source opens with its password — a PDF's user password, or an
63
- Office package's (MS-OFFCRYPTO, Agile and Standard):
62
+ An encrypted source opens with its password — a PDF's user or owner password,
63
+ or an Office package's (MS-OFFCRYPTO, Agile and Standard). A wrong one, or none
64
+ where one is needed, throws `WrongPasswordError`:
64
65
 
65
66
  ```ts
66
67
  const doc = Ream.parse(bytes, { password: 'secret' });
@@ -253,14 +254,17 @@ itself whether a file is a document to re-flow or a page to keep — a paper is
253
254
  mostly lines, a form is mostly marks — and there is nothing to configure: a
254
255
  caller cannot know which of the two it was handed. It reads modern compressed
255
256
  files (cross-reference + object streams) and encrypted ones (RC4 / AES — the
256
- user password is passed to `Ream.parse(bytes, { password })`, defaulting to the
257
- permissions-only case); a filter it does not carry can be handed to it through
258
- `Ream.parse(bytes, { filters })`.
257
+ user or owner password is passed to `Ream.parse(bytes, { password })`,
258
+ defaulting to the permissions-only case, and one that opens the file as neither
259
+ throws `WrongPasswordError`); a filter it does not carry can be handed to it
260
+ through `Ream.parse(bytes, { filters })`.
259
261
 
260
262
  A form or a drawing is not a reflowable document, so the reader keeps it as a
261
263
  page instead: every line stands where its glyphs stand, beside the artwork, the
262
264
  way up the page is shown. Which of the two readings a file gets it decides
263
- itself and records in the losses.
265
+ itself and records in the losses. Markdown, which places nothing, reads a page
266
+ kept only for its table's rules, or for letters the file names no character
267
+ for, as the document it is without them.
264
268
 
265
269
  **Reads PowerPoint, too.** `Ream.parse` accepts a `.pptx` and turns each slide
266
270
  into a page at the deck size — text boxes (with run formatting, alignment,
@@ -291,4 +295,4 @@ and known limitations.
291
295
 
292
296
  ## License
293
297
 
294
- [MIT](./LICENSE) © Alex Krassavin
298
+ [MIT](./LICENSE) — Copyright (c) 2026 Alexandr Krassavin
@@ -35,8 +35,9 @@ import { pdfReader } from "../../pdf-reader/reader.js";
35
35
  function toFlowDoc(doc, options = {}) {
36
36
  return doc.kind === "sheet" ? projectSheetDoc(doc, options) : doc;
37
37
  }
38
- function readToFlow(reader, bytes, options = {}) {
39
- const { doc, losses } = reader.read(bytes);
38
+ function readToFlow(reader, bytes, options = {}, placesNothing = false) {
39
+ const read = reader.read(bytes);
40
+ const { doc, losses } = (placesNothing ? read.flowing?.() : void 0) ?? read;
40
41
  return {
41
42
  doc: toFlowDoc(doc, options),
42
43
  losses
@@ -93,7 +94,10 @@ function createConverter(opts = {}) {
93
94
  };
94
95
  }
95
96
  if (to === "md") {
96
- const { doc: flow, losses: readLosses } = readToFlow(reader, bytes, rest.now ? { now: rest.now } : void 0);
97
+ const { doc: flow, losses: readLosses } = readToFlow(reader, bytes, {
98
+ ...rest.now ? { now: rest.now } : {},
99
+ spill: false
100
+ }, true);
97
101
  losses.push(...readLosses);
98
102
  const markdown = writeMarkdown(flow);
99
103
  losses.push(...markdown.losses);
@@ -130,6 +130,7 @@ export declare class Ream {
130
130
  readonly losses: ReadonlyArray<Loss>;
131
131
  private readonly source;
132
132
  private readonly readerId;
133
+ private readonly flowing?;
133
134
  /**
134
135
  * @param flow The interlayer — the parsed, format-neutral document tree.
135
136
  * @param sheet The native SpreadsheetML tree when the source is a spreadsheet
@@ -138,8 +139,13 @@ export declare class Ream {
138
139
  * @param source The original source bytes (kept only for docx auto-fonts and
139
140
  * PDF/A-3 `embedSource`).
140
141
  * @param readerId The id of the reader that parsed the source.
142
+ * @param flowing The source read as a document that flows, where the reader
143
+ * kept it as a page for marks Markdown drops anyway (see
144
+ * {@link ReadResult.flowing}).
141
145
  */
142
146
  private constructor();
147
+ /** The flowing reading, once Markdown has asked for it. */
148
+ private flowingRead;
143
149
  /**
144
150
  * Sniff the format and parse the bytes once into the {@link FlowDoc} interlayer.
145
151
  *
@@ -148,6 +154,9 @@ export declare class Ream {
148
154
  * encrypted source.
149
155
  * @returns A reusable {@link Ream} instance.
150
156
  * @throws Error when no registered reader recognizes the bytes.
157
+ * @throws WrongPasswordError when the bytes are encrypted — an Office package
158
+ * or a PDF — and the password does not open them, or none was given
159
+ * where one is needed.
151
160
  */
152
161
  static parse(bytes: Uint8Array, options?: ReamParseOptions): Ream;
153
162
  /** The source format id (`'docx'`, `'xlsx'`, …). */
@@ -15,6 +15,7 @@ import { writeXlsx } from "../../excel/xlsx-writer.js";
15
15
  import { writeHtml } from "../../html/html-writer.js";
16
16
  import { writeMarkdown } from "../../markdown/markdown-writer.js";
17
17
  import { writeSvg } from "../../svg/svg-writer.js";
18
+ import { WrongPasswordError } from "../crypto/password-error.js";
18
19
  import { DEFAULT_READERS, resolveFontsViaChain, toFlowDoc } from "./facade.js";
19
20
  import { familiesInFlow } from "../fonts/families.js";
20
21
  import { decryptPackage, isEncryptedPackage } from "../crypto/offcrypto.js";
@@ -58,6 +59,7 @@ var Ream = class Ream {
58
59
  losses;
59
60
  source;
60
61
  readerId;
62
+ flowing;
61
63
  /**
62
64
  * @param flow The interlayer — the parsed, format-neutral document tree.
63
65
  * @param sheet The native SpreadsheetML tree when the source is a spreadsheet
@@ -66,14 +68,20 @@ var Ream = class Ream {
66
68
  * @param source The original source bytes (kept only for docx auto-fonts and
67
69
  * PDF/A-3 `embedSource`).
68
70
  * @param readerId The id of the reader that parsed the source.
71
+ * @param flowing The source read as a document that flows, where the reader
72
+ * kept it as a page for marks Markdown drops anyway (see
73
+ * {@link ReadResult.flowing}).
69
74
  */
70
- constructor(flow, sheet, losses, source, readerId) {
75
+ constructor(flow, sheet, losses, source, readerId, flowing) {
71
76
  this.flow = flow;
72
77
  this.sheet = sheet;
73
78
  this.losses = losses;
74
79
  this.source = source;
75
80
  this.readerId = readerId;
81
+ this.flowing = flowing;
76
82
  }
83
+ /** The flowing reading, once Markdown has asked for it. */
84
+ flowingRead;
77
85
  /**
78
86
  * Sniff the format and parse the bytes once into the {@link FlowDoc} interlayer.
79
87
  *
@@ -82,22 +90,32 @@ var Ream = class Ream {
82
90
  * encrypted source.
83
91
  * @returns A reusable {@link Ream} instance.
84
92
  * @throws Error when no registered reader recognizes the bytes.
93
+ * @throws WrongPasswordError when the bytes are encrypted — an Office package
94
+ * or a PDF — and the password does not open them, or none was given
95
+ * where one is needed.
85
96
  */
86
97
  static parse(bytes, options = {}) {
87
98
  const readers = options.readers ?? DEFAULT_READERS;
88
99
  let source = bytes;
89
100
  if (isEncryptedPackage(bytes)) {
90
- if (options.password === void 0) throw new Error("Password-protected OOXML document (ECMA-376 §2.3 EncryptedPackage) — pass the password as `password`");
101
+ if (options.password === void 0) throw new WrongPasswordError("Password-protected OOXML document (ECMA-376 §2.3 EncryptedPackage) — pass the password as `password`");
91
102
  source = decryptPackage(bytes, options.password);
92
103
  }
93
104
  const reader = readers.find((r) => r.sniff(source));
94
105
  if (!reader) throw new Error(`Unrecognized document format (readers: ${readers.map((r) => r.id).join(", ")})`);
95
- const { doc, losses } = reader.read(source, {
106
+ const { doc, losses, flowing } = reader.read(source, {
96
107
  password: options.password,
97
108
  ...options.filters ? { filters: options.filters } : {}
98
109
  });
99
110
  const sheet = doc.kind === "sheet" ? doc : void 0;
100
- return new Ream(toFlowDoc(doc), sheet, losses, bytes, reader.id);
111
+ const reflow = flowing ? () => {
112
+ const read = flowing();
113
+ return {
114
+ doc: toFlowDoc(read.doc),
115
+ losses: read.losses
116
+ };
117
+ } : void 0;
118
+ return new Ream(toFlowDoc(doc), sheet, losses, bytes, reader.id, reflow);
101
119
  }
102
120
  /** The source format id (`'docx'`, `'xlsx'`, …). */
103
121
  get format() {
@@ -147,11 +165,16 @@ var Ream = class Ream {
147
165
  };
148
166
  }
149
167
  if (to === "md") {
168
+ if (this.flowing) {
169
+ this.flowingRead ??= this.flowing();
170
+ losses.splice(0, this.losses.length, ...this.flowingRead.losses);
171
+ }
150
172
  const source = this.sheet && options.sheetNames !== false ? projectSheetDoc(this.sheet, {
151
173
  ...options.now ? { now: options.now } : {},
152
174
  ...options.fileName ? { fileName: options.fileName } : {},
153
- sheetHeadings: true
154
- }) : flow;
175
+ sheetHeadings: true,
176
+ spill: false
177
+ }) : this.flowingRead?.doc ?? flow;
155
178
  const markdown = writeMarkdown(source, {
156
179
  ...options.images ? { images: options.images } : {},
157
180
  ...options.pageBreaks ? { pageBreaks: options.pageBreaks } : {}
@@ -1,2 +1 @@
1
- import { buildPkcs7Detached } from "./cms.js";
2
- export { buildPkcs7Detached };
1
+ export { buildPkcs7Detached } from "./cms.js";
@@ -1,8 +1,5 @@
1
- /** Thrown when the bytes are encrypted and the password does not open them. */
2
- export declare class WrongPasswordError extends Error {
3
- /** @param message The message to carry; the default states the failure. */
4
- constructor(message?: string);
5
- }
1
+ import { WrongPasswordError } from './password-error.js';
2
+ export { WrongPasswordError };
6
3
  /** Whether the bytes are an OOXML package encrypted per ECMA-376 §2.3. */
7
4
  export declare function isEncryptedPackage(bytes: Uint8Array): boolean;
8
5
  /**
@@ -1,14 +1,7 @@
1
1
  import { aesCbcDecrypt, aesEcbDecrypt, sha1, sha256, sha384, sha512 } from "./primitives.js";
2
2
  import { openCfb } from "../ole/cfb.js";
3
+ import { WrongPasswordError } from "./password-error.js";
3
4
  //#region src/core/crypto/offcrypto.ts
4
- /** Thrown when the bytes are encrypted and the password does not open them. */
5
- var WrongPasswordError = class extends Error {
6
- /** @param message The message to carry; the default states the failure. */
7
- constructor(message = "Wrong password for this document") {
8
- super(message);
9
- this.name = "WrongPasswordError";
10
- }
11
- };
12
5
  /** Whether the bytes are an OOXML package encrypted per ECMA-376 §2.3. */
13
6
  function isEncryptedPackage(bytes) {
14
7
  try {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Thrown when the bytes are encrypted and the password does not open them —
3
+ * the wrong one, or none where one is needed.
4
+ */
5
+ export declare class WrongPasswordError extends Error {
6
+ /** @param message The message to carry; the default states the failure. */
7
+ constructor(message?: string);
8
+ }
@@ -0,0 +1,14 @@
1
+ //#region src/core/crypto/password-error.ts
2
+ /**
3
+ * Thrown when the bytes are encrypted and the password does not open them —
4
+ * the wrong one, or none where one is needed.
5
+ */
6
+ var WrongPasswordError = class extends Error {
7
+ /** @param message The message to carry; the default states the failure. */
8
+ constructor(message = "Wrong password for this document") {
9
+ super(message);
10
+ this.name = "WrongPasswordError";
11
+ }
12
+ };
13
+ //#endregion
14
+ export { WrongPasswordError };
@@ -12,6 +12,15 @@ export interface ReadResult<TDoc> {
12
12
  readonly doc: TDoc;
13
13
  /** Losses recorded while reading. */
14
14
  readonly losses: LossReport;
15
+ /**
16
+ * The same source read again as a document that flows, for a medium that
17
+ * places nothing — Markdown. Present only where the reader kept the source as
18
+ * a page for marks such a medium drops anyway: a PDF whose words are mostly
19
+ * glyphs it names no character for, or whose tables are ruled, kept where its
20
+ * marks stand so that a paged medium can show them exactly. Called only by
21
+ * that medium, so the source is read twice only when it asks.
22
+ */
23
+ readonly flowing?: () => ReadResult<TDoc>;
15
24
  }
16
25
  /**
17
26
  * A synchronous reader for one source format (ir-design §7). All async I/O —
@@ -123,6 +123,7 @@ export declare function printableHeightPt(worksheet: ParsedWorksheet): number;
123
123
  interface PrintModelOptions {
124
124
  readonly screen?: boolean;
125
125
  readonly image?: boolean;
126
+ readonly spill?: boolean;
126
127
  readonly printArea?: CellRange;
127
128
  readonly gridLines: boolean;
128
129
  readonly titleRows?: {
@@ -903,24 +903,18 @@ function gridBody(worksheet, sharedStrings, styles, date1904, print) {
903
903
  const rotation = xf?.alignment?.textRotation;
904
904
  const rotated = rotation !== void 0 && rotation !== 0 && text.length > 0;
905
905
  const shrinkToFit = xf?.alignment?.shrinkToFit === true && !wrapText && !rotated;
906
- if (text.length > 0 && !merge && !wrapText && !rotated && !shrinkToFit && ws && (ws.type === "s" || ws.type === "str" || ws.type === "inlineStr") && (flow === "left" || flow === "center") && estimateChars(text) * charTwips(xf, styles, textTwipsUnit) > columnWidths[c]) {
907
- let availTwips = columnWidths[c];
906
+ if (print.spill !== false && text.length > 0 && !merge && !wrapText && !rotated && !shrinkToFit && ws && (ws.type === "s" || ws.type === "str" || ws.type === "inlineStr") && (flow === "left" || flow === "center") && estimateChars(text) * charTwips(xf, styles, textTwipsUnit) > columnWidths[c]) {
908
907
  let cc = c + 1;
909
908
  const neighbourIsFree = (col) => !cellHasContent(cellMatrix[r]?.[col]) && !insideMerge.has(key(absR, col + colStart)) && !mergeOrigins.has(key(absR, col + colStart)) && (!cellPaintsSomething(cellMatrix[r]?.[col], styles) || spanPreservesPaint(cellMatrix[r]?.[col], styles, shading, borders)) && !sparklineByCell.has(key(absR, col + colStart)) && !(dropdownRanges.length > 0 && rangesCover(dropdownRanges, absR, col + colStart));
910
909
  const bandEnd = bandEndOfCol.get(c) ?? colCount - 1;
911
- while (cc <= bandEnd && (hiddenCols.has(cc) || neighbourIsFree(cc))) {
912
- if (!hiddenCols.has(cc)) availTwips += columnWidths[cc];
913
- cc++;
914
- }
910
+ while (cc <= bandEnd && (hiddenCols.has(cc) || neighbourIsFree(cc))) cc++;
915
911
  if (cc <= bandEnd && !cellHasContent(cellMatrix[r]?.[cc]) && !sparklineByCell.has(key(absR, cc + colStart)) && !(dropdownRanges.length > 0 && rangesCover(dropdownRanges, absR, cc + colStart))) {
916
912
  const rule = absorbableRightRule(cellMatrix[r]?.[cc], styles, shading, borders);
917
913
  if (rule) {
918
- availTwips += columnWidths[cc];
919
914
  overflowRightRule = rule;
920
915
  cc++;
921
916
  }
922
917
  }
923
- text = clipToWidth(text, availTwips * 1.1, charTwips(xf, styles, textTwipsUnit));
924
918
  if (cc > c + 1) {
925
919
  overflowSpan = 0;
926
920
  for (let k = c; k < cc; k++) if (!hiddenCols.has(k)) overflowSpan++;
@@ -932,11 +926,6 @@ function gridBody(worksheet, sharedStrings, styles, date1904, print) {
932
926
  paintColumns++;
933
927
  }
934
928
  }
935
- } else if (text.length > 0 && merge && !wrapText && !rotated && !shrinkToFit) {
936
- const lastCol = Math.min(merge.endColumn - colStart, bandEndOfCol.get(c) ?? colCount - 1);
937
- let availTwips = 0;
938
- for (let k = c; k <= lastCol; k++) if (!hiddenCols.has(k)) availTwips += columnWidths[k] ?? 0;
939
- text = clipToWidth(text, availTwips * 1.1, charTwips(xf, styles, textTwipsUnit));
940
929
  }
941
930
  if (shrinkToFit && ws && text.length > 0) {
942
931
  const drawnTwips = estimateChars(text) / charWidthUnits("0") * charTwips(xf, styles, textTwipsUnit);
@@ -1004,7 +993,7 @@ function gridBody(worksheet, sharedStrings, styles, date1904, print) {
1004
993
  runs
1005
994
  }
1006
995
  }));
1007
- if (text.length > 0 && !merge && !wrapText && !rotated && !shrinkToFit && c > 0 && flow === "right" && estimateChars(text) * charTwips(xf, styles, textTwipsUnit) > columnWidths[c]) leftOverflow.push({
996
+ if (print.spill !== false && text.length > 0 && !merge && !wrapText && !rotated && !shrinkToFit && c > 0 && flow === "right" && estimateChars(text) * charTwips(xf, styles, textTwipsUnit) > columnWidths[c]) leftOverflow.push({
1008
997
  index: cells.length,
1009
998
  column: c,
1010
999
  needTwips: estimateChars(text) * charTwips(xf, styles, textTwipsUnit) - columnWidths[c],
@@ -1998,20 +1987,6 @@ function estimateChars(text) {
1998
1987
  for (const ch of text) n += charWidthUnits(ch);
1999
1988
  return n;
2000
1989
  }
2001
- /** The longest prefix of `text` that fits `availTwips`, by the same estimate. */
2002
- function clipToWidth(text, availTwips, perChar) {
2003
- if (perChar <= 0) return text;
2004
- const budget = availTwips / perChar;
2005
- let used = 0;
2006
- let i = 0;
2007
- for (const ch of text) {
2008
- const w = charWidthUnits(ch);
2009
- if (used + w > budget) break;
2010
- used += w;
2011
- i += ch.length;
2012
- }
2013
- return i >= text.length ? text : text.slice(0, Math.max(1, i));
2014
- }
2015
1990
  /**
2016
1991
  * Widen each right-aligned cell that overflows leftwards over the free columns
2017
1992
  * before it, deleting the cells those columns produced.
@@ -69,6 +69,14 @@ export interface ProjectSheetOptions {
69
69
  * on it (`fitPagesToContent`). The SVG target asks for this.
70
70
  */
71
71
  readonly wholeSheetPages?: boolean;
72
+ /**
73
+ * Let a cell's text run on over its empty neighbours, as a printed or shown
74
+ * sheet does — modelled as a span from the cell over them. On by default.
75
+ * Off for a medium that keeps cells and not widths: markdown has no column
76
+ * for text to run on into, and a spill to the LEFT moved a right-aligned
77
+ * label into the column before its own.
78
+ */
79
+ readonly spill?: boolean;
72
80
  }
73
81
  /**
74
82
  * Project a {@link SheetDoc} into a {@link FlowDoc} (E-SHEET SA2): each grid sheet
@@ -82,6 +82,7 @@ function projectSheetDoc(sheet, options = {}) {
82
82
  const titleRows = screen ? void 0 : resolvePrintTitleRows(sheet.definedNames, sheetIdx);
83
83
  const gridBody = worksheetToBody(ws.grid, sheet.sharedStrings, sheet.styles, sheet.date1904, {
84
84
  ...screen ? { screen } : {},
85
+ ...options.spill === false ? { spill: false } : {},
85
86
  ...screen && options.wholeSheetPages === true ? { image: true } : {},
86
87
  ...printArea ? { printArea } : {},
87
88
  ...titleRows ? { titleRows } : {},
package/dist/esm/index.js CHANGED
@@ -19,7 +19,8 @@ import { docxWriter, writeDocx } from "./word/docx-writer.js";
19
19
  import { htmlWriter, writeHtml } from "./html/html-writer.js";
20
20
  import { markdownWriter, writeMarkdown } from "./markdown/markdown-writer.js";
21
21
  import { svgWriter, writeSvg } from "./svg/svg-writer.js";
22
+ import { WrongPasswordError } from "./core/crypto/password-error.js";
22
23
  import { createConverter } from "./core/converter/facade.js";
23
- import { WrongPasswordError, isEncryptedPackage } from "./core/crypto/offcrypto.js";
24
+ import { isEncryptedPackage } from "./core/crypto/offcrypto.js";
24
25
  import { Ream } from "./core/converter/ream.js";
25
26
  export { ConversionLossError, FEATURES, FontRegistry, NO_FONT, Ream, ResourceStore, WrongPasswordError, callerFontProvider, chainProviders, createConverter, createHyphenator, createLanguageHyphenator, docxReader, docxWriter, eighthPtToPt, embeddedDocFontProvider, emuToPt, fetchFontSet, formatLoss, getHyphenator, halfPtToPt, htmlWriter, inchToPt, isEmbeddingRestricted, isEncryptedPackage, layoutStyledDocument, localFontProvider, markdownWriter, mmToPt, parseTtf, pt, pxToPt, readDocx, readOs2FsType, readXlsx, remoteFontProvider, renderStyledPdf, resolveFamilyKey, signPdf, splitPatternBundle, subsetTtf, svgWriter, twipsToPt, writeDocx, writeHtml, writeMarkdown, writeSvg, xlsxReader };
@@ -4983,7 +4983,7 @@ function layoutTableCell(cell, tableProps, widthPt, options, fontResources, imag
4983
4983
  const block = layoutParagraphBlock(el.paragraph, options, fontResources, imageResources, noWrap ? NO_WRAP_MEASURE_WIDTH : measureWidth);
4984
4984
  const cutAt = widthPt - padLeftPt;
4985
4985
  if (noWrap && (block.lines[0]?.contentWidthPt ?? 0) > cutAt) clipped = true;
4986
- const keep = noWrap ? clipLineToWidth(block.lines, cutAt, cell.properties.hashOnOverflow === true) : block.lines;
4986
+ const keep = noWrap ? oneLineInBox(block.lines, cutAt, cell.properties.hashOnOverflow === true) : block.lines;
4987
4987
  const markOnly = cell.properties.hideMark === true && el.paragraph.runs.every((r) => r.text === "");
4988
4988
  if (!markOnly) openParagraph(block.spacingBeforePt);
4989
4989
  for (const line of keep) {
@@ -6781,7 +6781,7 @@ function emitRowChunk(out, row, marginLeft, cursorY, pageHeight, colCount, cellS
6781
6781
  textY -= cell.lineGaps?.get(lineIdx) ?? 0;
6782
6782
  const h = computeLineHeight(line, line.resolved);
6783
6783
  textY -= h;
6784
- const offset = alignmentOffset(line.resolved.alignment, line.contentWidthPt, cell.widthPt - cell.padLeftPt - cell.padRightPt);
6784
+ const offset = (cell.clipped === true ? overrunOffset : alignmentOffset)(line.resolved.alignment, line.contentWidthPt, cell.widthPt - cell.padLeftPt - cell.padRightPt);
6785
6785
  const indentLeft = line.resolved.indentLeft + (line.firstLine ? line.resolved.indentFirstLine : 0);
6786
6786
  out.push({
6787
6787
  type: "line",
@@ -6920,51 +6920,37 @@ function emitCellBorders(out, cell, cellX, cellY, rowHeight, pageHeight, rowIdx,
6920
6920
  }
6921
6921
  var NO_WRAP_MEASURE_WIDTH = 1e6;
6922
6922
  /**
6923
- * One line, trimmed to the glyph that STRADDLES `widthPt` — what a non-wrapping
6924
- * spreadsheet cell shows. Excel and LibreOffice paint that glyph and cut it
6925
- * through the middle, and so do we now: the caller marks the cell and the
6926
- * emitters clip the line to its box (TextLineItem.clip). Stopping at the last
6927
- * glyph that wholly fits is what dropped a character off every cut cell.
6923
+ * The one line a non-wrapping spreadsheet cell shows — WHOLE. Excel and
6924
+ * LibreOffice set such a cell's text on one line and let its box cut it, through
6925
+ * the glyph the edge falls on: on the right of left-aligned text, on both sides
6926
+ * of centred text, on the left of right-aligned text. So the caller marks the
6927
+ * cell, sets the line by its alignment (see {@link overrunOffset}) and the
6928
+ * emitters clip it to the box (TextLineItem.clip).
6929
+ *
6930
+ * It was trimmed to the glyph that straddles the edge instead, which cut every
6931
+ * centred and right-aligned cell on the wrong side and left the page's text
6932
+ * holding only what showed — "Монтаж кровл" of "Монтаж кровли".
6933
+ *
6934
+ * @param lines The cell paragraph's lines, laid out unbroken.
6935
+ * @param widthPt How far the cell's box reaches.
6936
+ * @param hashOnOverflow Whether a number too wide for the box shows as `#`.
6937
+ * @returns The first line, or the `#` that stand for it.
6928
6938
  */
6929
- function clipLineToWidth(lines, widthPt, hashOnOverflow = false) {
6939
+ function oneLineInBox(lines, widthPt, hashOnOverflow = false) {
6930
6940
  const line = lines[0];
6931
6941
  if (!line) return [];
6932
- if (line.contentWidthPt <= widthPt) return [line];
6933
- if (hashOnOverflow) return [hashFill(line, widthPt)];
6934
- const tokens = [];
6935
- let used = 0;
6936
- for (const token of line.tokens) {
6937
- const advance = token.widthPt;
6938
- if (used + advance <= widthPt) {
6939
- tokens.push(token);
6940
- used += advance;
6941
- continue;
6942
- }
6943
- if (token.kind === "text") {
6944
- const room = widthPt - used;
6945
- let text = "";
6946
- let textWidth = 0;
6947
- for (const ch of token.text) {
6948
- if (textWidth >= room) break;
6949
- text += ch;
6950
- textWidth = token.font.measure.textWidthPt(text, token.fontSizePt);
6951
- }
6952
- if (text.length > 0) {
6953
- tokens.push({
6954
- ...token,
6955
- text,
6956
- widthPt: textWidth
6957
- });
6958
- used += textWidth;
6959
- }
6960
- }
6961
- break;
6962
- }
6963
- return [{
6964
- ...line,
6965
- tokens,
6966
- contentWidthPt: used
6967
- }];
6942
+ if (hashOnOverflow && line.contentWidthPt > widthPt) return [hashFill(line, widthPt)];
6943
+ return [line];
6944
+ }
6945
+ /**
6946
+ * {@link alignmentOffset} for a line that may be wider than its room: the
6947
+ * offset may go below zero, putting the start of a centred or right-aligned
6948
+ * line before the box for the box's clip to cut.
6949
+ */
6950
+ function overrunOffset(alignment, lineWidth, available) {
6951
+ if (alignment === "right") return available - lineWidth;
6952
+ if (alignment === "center") return (available - lineWidth) / 2;
6953
+ return 0;
6968
6954
  }
6969
6955
  /**
6970
6956
  * The line as a row of `#` filling `widthPt` — what Excel and LibreOffice put in
@@ -145,13 +145,14 @@ function emitShape(out, shape, ctx) {
145
145
  function emitParagraph(out, p, ctx) {
146
146
  const resolved = resolveParagraphProperties(p.properties, EMPTY_STYLE_SHEET);
147
147
  const anchors = bookmarkAnchors(p, ctx);
148
- const text = inlineRuns(p.runs, p, ctx).replace(/^[ \t]+/, "");
148
+ const level = headingLevelOf(resolved);
149
+ const asHeading = level !== void 0 && !ctx.inCell;
150
+ const text = inlineRuns(p.runs, p, ctx, asHeading).replace(/^[ \t]+/, "");
149
151
  const inline = anchors + text;
150
152
  const marker = markerText(p, ctx);
151
153
  reportParagraphLosses(resolved, ctx);
152
154
  if (breaksPage(p, resolved)) emitRule(out, ctx);
153
155
  const empty = isBlank(text) && anchors.length === 0;
154
- const level = headingLevelOf(resolved);
155
156
  if (level !== void 0) {
156
157
  if (empty) return;
157
158
  ctx.list.length = 0;
@@ -602,8 +603,11 @@ function cellInline(cell, ctx) {
602
603
  * characters on either side of it (§6.2), and those belong to the NEIGHBOURING
603
604
  * spans: the first pass coalesces runs into spans, the second dresses each
604
605
  * span knowing what stands beside it.
606
+ *
607
+ * @param heading Whether the paragraph is a heading, which every renderer of
608
+ * one sets strong already.
605
609
  */
606
- function inlineRuns(runs, p, ctx) {
610
+ function inlineRuns(runs, p, ctx, heading = false) {
607
611
  const pieces = [];
608
612
  let pending;
609
613
  const flush = () => {
@@ -621,8 +625,11 @@ function inlineRuns(runs, p, ctx) {
621
625
  const resolved = resolveRunProperties(run.properties, p.properties, EMPTY_STYLE_SHEET);
622
626
  reportRunLosses(resolved, ctx);
623
627
  const marks = marksOf(resolved, run);
624
- if (pending && sameMarks(pending.marks, marks)) pending.text += run.text;
625
- else {
628
+ const joined = pending && joinedMarks(pending, marks, run.text);
629
+ if (pending && joined) {
630
+ pending.marks = joined;
631
+ pending.text += run.text;
632
+ } else {
626
633
  flush();
627
634
  pending = {
628
635
  marks,
@@ -631,18 +638,52 @@ function inlineRuns(runs, p, ctx) {
631
638
  }
632
639
  }
633
640
  flush();
641
+ const spans = pieces.filter((piece) => "marks" in piece && !isWhitespace(piece.text));
642
+ const plain = heading && spans.length > 0 && spans.every((piece) => piece.marks.bold) ? pieces.map((piece) => "marks" in piece ? {
643
+ ...piece,
644
+ marks: {
645
+ ...piece.marks,
646
+ bold: false
647
+ }
648
+ } : piece) : pieces;
634
649
  let out = "";
635
- for (let i = 0; i < pieces.length; i++) {
636
- const piece = pieces[i];
650
+ for (let i = 0; i < plain.length; i++) {
651
+ const piece = plain[i];
637
652
  if ("literal" in piece) {
638
653
  out += piece.literal;
639
654
  continue;
640
655
  }
641
- out += applyMarks(piece.text, piece.marks, ctx, out.slice(-1), leadOf(pieces, i + 1));
656
+ out += applyMarks(piece.text, piece.marks, ctx, out.slice(-1), leadOf(plain, i + 1));
642
657
  }
643
658
  return out;
644
659
  }
645
660
  /**
661
+ * The marks a span keeps when the next run joins it, or nothing where the two
662
+ * must stay apart.
663
+ *
664
+ * The same marks join, as they always did. And a run of WHITESPACE joins its
665
+ * neighbour whatever its emphasis, for emphasis does not show on a space: a
666
+ * PDF from TeX sets no space between two words at all, the reader writes one
667
+ * in the plain face, and a bold title came back `**Trace-based**
668
+ * **Just-in-Time** **Type**`, broken at every word. A line under or through a
669
+ * space does show, and so does a link over it — those must still agree.
670
+ *
671
+ * @param span The span so far.
672
+ * @param next The next run's marks.
673
+ * @param text The next run's text.
674
+ * @returns The marks the joined span carries, or `undefined`.
675
+ */
676
+ function joinedMarks(span, next, text) {
677
+ if (sameMarks(span.marks, next)) return span.marks;
678
+ if (span.marks.strike !== next.strike || span.marks.underline !== next.underline || span.marks.href !== next.href || span.marks.anchor !== next.anchor) return void 0;
679
+ if (isWhitespace(text)) return span.marks;
680
+ if (isWhitespace(span.text)) return next;
681
+ }
682
+ /** Whether the text is whitespace and nothing else — a space, a tab, a break. */
683
+ function isWhitespace(text) {
684
+ return /^\s+$/u.test(text);
685
+ }
686
+ /**
646
687
  * The first character the next span will contribute. A dressed span always
647
688
  * begins with its delimiter or its html tag — both punctuation — so a marked
648
689
  * neighbour answers without being rendered first.
@@ -7,16 +7,28 @@ export interface Decryptor {
7
7
  */
8
8
  decrypt: (value: PdfValue, objNum: number, gen: number) => PdfValue;
9
9
  }
10
+ /**
11
+ * What {@link buildDecryptor} makes of a file: the decryptor, or why there is
12
+ * none — the password opens it as neither its user nor its owner, or the file is
13
+ * locked by a handler this does not implement.
14
+ */
15
+ export type DecryptorResult = {
16
+ readonly decryptor: Decryptor;
17
+ } | {
18
+ readonly refused: 'password' | 'handler';
19
+ };
10
20
  /**
11
21
  * Build a {@link Decryptor} from the `/Encrypt` dictionary and the file `/ID`
12
- * (ISO 32000 §7.6.3 / ISO 32000-2 §7.6.4). Derives the file key for the
13
- * handler's revision — R6/V5 via `/UE` (AES-256), else the legacy MD5 key — and
22
+ * (ISO 32000 §7.6.3 / ISO 32000-2 §7.6.4). Authenticates the password as the
23
+ * user's and then as the owner's, derives the file key for the handler's
24
+ * revision — R6/V5 via `/UE` or `/OE` (AES-256), else the legacy MD5 key — and
14
25
  * picks the cipher (RC4, AES-128 `/AESV2`, AES-256 `/AESV3`).
15
26
  *
16
27
  * @param encrypt The `/Encrypt` dictionary.
17
28
  * @param idArray The trailer `/ID` array (its first element seeds the legacy key).
18
- * @param password The user password (EP14); the empty string opens permissions-only encryption.
19
- * @returns A decryptor, or `undefined` for a non-Standard filter, an unencrypting
20
- * crypt filter, or a password that fails key validation.
29
+ * @param password The password (EP14), the user's or the owner's; the empty
30
+ * string opens permissions-only encryption.
31
+ * @returns The decryptor, or why there is none: a password the file's own
32
+ * verifiers refuse, or a non-Standard filter or unencrypting crypt filter.
21
33
  */
22
- export declare function buildDecryptor(encrypt: PdfDict, idArray: PdfValue | undefined, password?: string): Decryptor | undefined;
34
+ export declare function buildDecryptor(encrypt: PdfDict, idArray: PdfValue | undefined, password?: string): DecryptorResult;
@@ -45,50 +45,73 @@ var ZERO16 = /* @__PURE__ */ new Uint8Array(16);
45
45
  var EMPTY = /* @__PURE__ */ new Uint8Array(0);
46
46
  /**
47
47
  * Build a {@link Decryptor} from the `/Encrypt` dictionary and the file `/ID`
48
- * (ISO 32000 §7.6.3 / ISO 32000-2 §7.6.4). Derives the file key for the
49
- * handler's revision — R6/V5 via `/UE` (AES-256), else the legacy MD5 key — and
48
+ * (ISO 32000 §7.6.3 / ISO 32000-2 §7.6.4). Authenticates the password as the
49
+ * user's and then as the owner's, derives the file key for the handler's
50
+ * revision — R6/V5 via `/UE` or `/OE` (AES-256), else the legacy MD5 key — and
50
51
  * picks the cipher (RC4, AES-128 `/AESV2`, AES-256 `/AESV3`).
51
52
  *
52
53
  * @param encrypt The `/Encrypt` dictionary.
53
54
  * @param idArray The trailer `/ID` array (its first element seeds the legacy key).
54
- * @param password The user password (EP14); the empty string opens permissions-only encryption.
55
- * @returns A decryptor, or `undefined` for a non-Standard filter, an unencrypting
56
- * crypt filter, or a password that fails key validation.
55
+ * @param password The password (EP14), the user's or the owner's; the empty
56
+ * string opens permissions-only encryption.
57
+ * @returns The decryptor, or why there is none: a password the file's own
58
+ * verifiers refuse, or a non-Standard filter or unencrypting crypt filter.
57
59
  */
58
60
  function buildDecryptor(encrypt, idArray, password = "") {
59
61
  const filter = encrypt.get("Filter");
60
- if (!(filter instanceof PdfName) || filter.value !== "Standard") return void 0;
62
+ if (!(filter instanceof PdfName) || filter.value !== "Standard") return { refused: "handler" };
61
63
  const v = numOf(encrypt.get("V"));
62
64
  const r = numOf(encrypt.get("R"));
65
+ const o = strBytes(encrypt.get("O"));
66
+ const u = strBytes(encrypt.get("U"));
67
+ const methods = cryptMethods(encrypt, v, r);
68
+ if (methods.stream === "none" && methods.string === "none") return { decryptor: { decrypt: (value) => value } };
63
69
  let fileKey;
64
- let method;
65
- if (r >= 5 || v >= 5) {
66
- fileKey = deriveKeyR6(strBytes(encrypt.get("U")), strBytes(encrypt.get("UE")), password);
67
- method = "aesv3";
68
- } else {
69
- const keyLen = (numOf(encrypt.get("Length")) || 40) / 8;
70
+ if (r >= 5 || v >= 5) fileKey = userKeyR6(u, strBytes(encrypt.get("UE")), password) ?? ownerKeyR6(o, u, strBytes(encrypt.get("OE")), password);
71
+ else {
72
+ const keyLen = Math.max(5, Math.min(16, (numOf(encrypt.get("Length")) || 40) / 8));
70
73
  const id0 = Array.isArray(idArray) ? strBytes(idArray[0]) : EMPTY;
71
- fileKey = deriveKeyLegacy(strBytes(encrypt.get("O")), numOf(encrypt.get("P")) | 0, id0, r, Math.max(5, Math.min(16, keyLen)), encrypt.get("EncryptMetadata") !== false, password);
72
- const m = cipherMethod(encrypt, v);
73
- if (m === void 0) return void 0;
74
- method = m;
74
+ const keyFor = (padded) => {
75
+ const key = deriveKeyLegacy(o, numOf(encrypt.get("P")) | 0, id0, r, keyLen, encrypt.get("EncryptMetadata") !== false, padded);
76
+ return userKeyMatches(key, r, id0, u) ? key : void 0;
77
+ };
78
+ fileKey = keyFor(padPassword(password)) ?? keyFor(userPasswordFromOwner(password, o, r, keyLen));
75
79
  }
76
- if (!fileKey) return void 0;
80
+ if (!fileKey) return { refused: "password" };
77
81
  const key = fileKey;
78
- return { decrypt: (value, objNum, gen) => decryptValue(value, objNum, gen, key, method) };
82
+ return { decryptor: { decrypt: (value, objNum, gen) => decryptValue(value, objNum, gen, key, methods) } };
79
83
  }
80
- function deriveKeyLegacy(o, p, id0, r, keyLen, encryptMetadata, password) {
84
+ function deriveKeyLegacy(o, p, id0, r, keyLen, encryptMetadata, padded) {
81
85
  const tail = r >= 4 && !encryptMetadata ? Uint8Array.from([
82
86
  255,
83
87
  255,
84
88
  255,
85
89
  255
86
90
  ]) : EMPTY;
87
- let hash = md5(concat(padPassword(password), o.subarray(0, 32), p32le(p), id0, tail));
91
+ let hash = md5(concat(padded, o.subarray(0, 32), p32le(p), id0, tail));
88
92
  if (r >= 3) for (let i = 0; i < 50; i++) hash = md5(hash.subarray(0, keyLen));
89
93
  return hash.subarray(0, keyLen);
90
94
  }
91
- function deriveKeyR6(u, ue, password) {
95
+ function userKeyMatches(key, r, id0, u) {
96
+ if (r <= 2) return u.length < 32 || equal(rc4(key, PAD), u.subarray(0, 32));
97
+ if (u.length < 16) return true;
98
+ let x = rc4(key, md5(concat(PAD, id0)));
99
+ for (let i = 1; i <= 19; i++) x = rc4(xorKey(key, i), x);
100
+ return equal(x, u.subarray(0, 16));
101
+ }
102
+ function userPasswordFromOwner(owner, o, r, keyLen) {
103
+ let hash = md5(padPassword(owner));
104
+ if (r >= 3) for (let i = 0; i < 50; i++) hash = md5(hash);
105
+ const key = hash.subarray(0, r >= 3 ? keyLen : 5);
106
+ let x = o.subarray(0, 32);
107
+ if (r <= 2) return rc4(key, x);
108
+ for (let i = 19; i >= 0; i--) x = rc4(xorKey(key, i), x);
109
+ return x;
110
+ }
111
+ function xorKey(key, i) {
112
+ return key.map((b) => b ^ i);
113
+ }
114
+ function userKeyR6(u, ue, password) {
92
115
  if (u.length < 48 || ue.length < 32) return void 0;
93
116
  const pw = passwordBytes(password);
94
117
  const validationSalt = u.subarray(32, 40);
@@ -97,6 +120,14 @@ function deriveKeyR6(u, ue, password) {
97
120
  const intermediate = hash2B(pw, keySalt, EMPTY);
98
121
  return aesCbcDecrypt(intermediate, ZERO16, ue.subarray(0, 32), false);
99
122
  }
123
+ function ownerKeyR6(o, u, oe, password) {
124
+ if (o.length < 48 || u.length < 48 || oe.length < 32) return void 0;
125
+ const pw = passwordBytes(password);
126
+ const udata = u.subarray(0, 48);
127
+ if (!equal(hash2B(pw, o.subarray(32, 40), udata), o.subarray(0, 32))) return void 0;
128
+ const intermediate = hash2B(pw, o.subarray(40, 48), udata);
129
+ return aesCbcDecrypt(intermediate, ZERO16, oe.subarray(0, 32), false);
130
+ }
100
131
  function padPassword(password) {
101
132
  const raw = strToBytes(password);
102
133
  const out = /* @__PURE__ */ new Uint8Array(32);
@@ -123,19 +154,31 @@ function hash2B(pw, salt, udata) {
123
154
  }
124
155
  return k.subarray(0, 32);
125
156
  }
126
- function cipherMethod(encrypt, v) {
127
- if (v < 4) return "rc4";
128
- const stmf = encrypt.get("StmF");
129
- const cfName = stmf instanceof PdfName ? stmf.value : "StdCF";
130
- if (cfName === "Identity") return void 0;
131
- const cf = encrypt.get("CF");
132
- const cfDict = cf instanceof Map ? cf.get(cfName) : void 0;
133
- const cfm = cfDict instanceof Map ? cfDict.get("CFM") : void 0;
134
- if (cfm instanceof PdfName) {
135
- if (cfm.value === "AESV2") return "aesv2";
136
- if (cfm.value === "AESV3") return "aesv3";
137
- }
138
- return "rc4";
157
+ function cryptMethods(encrypt, v, r) {
158
+ if (v < 4) return {
159
+ stream: "rc4",
160
+ string: "rc4"
161
+ };
162
+ const fallback = r >= 5 || v >= 5 ? "aesv3" : "rc4";
163
+ const methodOf = (key) => {
164
+ const named = encrypt.get(key);
165
+ const cfName = named instanceof PdfName ? named.value : "StdCF";
166
+ if (cfName === "Identity") return "none";
167
+ const cf = encrypt.get("CF");
168
+ const cfDict = cf instanceof Map ? cf.get(cfName) : void 0;
169
+ const cfm = cfDict instanceof Map ? cfDict.get("CFM") : void 0;
170
+ if (cfm instanceof PdfName) {
171
+ if (cfm.value === "AESV2") return "aesv2";
172
+ if (cfm.value === "AESV3") return "aesv3";
173
+ if (cfm.value === "V2") return "rc4";
174
+ if (cfm.value === "None") return "none";
175
+ }
176
+ return fallback;
177
+ };
178
+ return {
179
+ stream: methodOf("StmF"),
180
+ string: methodOf("StrF")
181
+ };
139
182
  }
140
183
  function objectKey(fileKey, objNum, gen, aes) {
141
184
  const extra = aes ? AES_SALT : EMPTY;
@@ -150,6 +193,7 @@ function objectKey(fileKey, objNum, gen, aes) {
150
193
  return md5(seed).subarray(0, Math.min(fileKey.length + 5, 16));
151
194
  }
152
195
  function decryptBytes(data, objNum, gen, fileKey, method) {
196
+ if (method === "none") return data;
153
197
  if (method === "aesv3") {
154
198
  if (data.length < 16) return data;
155
199
  return aesCbcDecrypt(fileKey, data.subarray(0, 16), data.subarray(16), true);
@@ -161,18 +205,18 @@ function decryptBytes(data, objNum, gen, fileKey, method) {
161
205
  }
162
206
  return rc4(key, data);
163
207
  }
164
- function decryptValue(value, objNum, gen, fileKey, method) {
165
- if (typeof value === "string") return latin1(decryptBytes(strToBytes(value), objNum, gen, fileKey, method));
166
- if (value instanceof PdfHexString) return new PdfHexString(decryptBytes(value.bytes, objNum, gen, fileKey, method));
208
+ function decryptValue(value, objNum, gen, fileKey, methods) {
209
+ if (typeof value === "string") return latin1(decryptBytes(strToBytes(value), objNum, gen, fileKey, methods.string));
210
+ if (value instanceof PdfHexString) return new PdfHexString(decryptBytes(value.bytes, objNum, gen, fileKey, methods.string));
167
211
  if (value instanceof PdfStream) {
168
- const dict = decryptValue(value.dict, objNum, gen, fileKey, method);
169
- const data = decryptBytes(value.data, objNum, gen, fileKey, method);
212
+ const dict = decryptValue(value.dict, objNum, gen, fileKey, methods);
213
+ const data = decryptBytes(value.data, objNum, gen, fileKey, methods.stream);
170
214
  return new PdfStream(dict instanceof Map ? dict : value.dict, data);
171
215
  }
172
- if (Array.isArray(value)) return value.map((v) => decryptValue(v, objNum, gen, fileKey, method));
216
+ if (Array.isArray(value)) return value.map((v) => decryptValue(v, objNum, gen, fileKey, methods));
173
217
  if (value instanceof Map) {
174
218
  const out = /* @__PURE__ */ new Map();
175
- for (const [k, v] of value) out.set(k, decryptValue(v, objNum, gen, fileKey, method));
219
+ for (const [k, v] of value) out.set(k, decryptValue(v, objNum, gen, fileKey, methods));
176
220
  return out;
177
221
  }
178
222
  return value;
@@ -35,6 +35,7 @@ export declare class PdfFile {
35
35
  private readonly cache;
36
36
  private readonly objStmCache;
37
37
  private decryptor;
38
+ private refused;
38
39
  private encryptObjNum;
39
40
  private constructor();
40
41
  /**
@@ -71,9 +72,14 @@ export declare class PdfFile {
71
72
  get catalog(): PdfDict;
72
73
  /**
73
74
  * The document is encrypted but no decryptor could be built (an unsupported
74
- * handler, or a wrong/missing user password) — its content is unreadable.
75
+ * handler, or a refused password) — its content is unreadable.
75
76
  */
76
77
  get encryptionUnsupported(): boolean;
78
+ /**
79
+ * §7.6.3.4 — the document is encrypted and the password it was opened with is
80
+ * neither its user's nor its owner's, by the verifiers the file itself stores.
81
+ */
82
+ get passwordRefused(): boolean;
77
83
  /** The leaf pages, in document order, each with its inherited `/MediaBox` and `/Resources`. */
78
84
  pages(): Array<PdfPage>;
79
85
  /**
@@ -31,6 +31,7 @@ var PdfFile = class PdfFile {
31
31
  cache = /* @__PURE__ */ new Map();
32
32
  objStmCache = /* @__PURE__ */ new Map();
33
33
  decryptor;
34
+ refused;
34
35
  encryptObjNum = -1;
35
36
  constructor(buf, xref, trailer, filters = {}) {
36
37
  this.buf = buf;
@@ -85,7 +86,9 @@ var PdfFile = class PdfFile {
85
86
  const enc = this.resolve(encVal);
86
87
  if (!(enc instanceof Map)) return;
87
88
  const id = this.trailer.get("ID");
88
- this.decryptor = buildDecryptor(enc, Array.isArray(id) ? id : void 0, password);
89
+ const built = buildDecryptor(enc, Array.isArray(id) ? id : void 0, password);
90
+ if ("decryptor" in built) this.decryptor = built.decryptor;
91
+ else this.refused = built.refused;
89
92
  }
90
93
  /** Resolve a stream's `/Length` when it is an indirect reference. */
91
94
  lengthResolver = (r) => {
@@ -147,11 +150,18 @@ var PdfFile = class PdfFile {
147
150
  }
148
151
  /**
149
152
  * The document is encrypted but no decryptor could be built (an unsupported
150
- * handler, or a wrong/missing user password) — its content is unreadable.
153
+ * handler, or a refused password) — its content is unreadable.
151
154
  */
152
155
  get encryptionUnsupported() {
153
156
  return this.trailer.get("Encrypt") !== void 0 && this.decryptor === void 0;
154
157
  }
158
+ /**
159
+ * §7.6.3.4 — the document is encrypted and the password it was opened with is
160
+ * neither its user's nor its owner's, by the verifiers the file itself stores.
161
+ */
162
+ get passwordRefused() {
163
+ return this.refused === "password";
164
+ }
155
165
  /** The leaf pages, in document order, each with its inherited `/MediaBox` and `/Resources`. */
156
166
  pages() {
157
167
  const out = [];
@@ -14,11 +14,15 @@ import { FlowDoc } from '../core/ir/flow.js';
14
14
  * @param filters Decoders for `/Filter` names this reader does not implement
15
15
  * (§7.4); see {@link StreamFilters}.
16
16
  * @returns The reconstructed FlowDoc and its accumulated {@link Loss} report.
17
+ * @throws WrongPasswordError when the file is encrypted and the password opens
18
+ * it neither as its user nor as its owner — none given included.
17
19
  *
18
20
  * Which of the two readings a file gets is the FILE's to decide and no
19
21
  * caller's: a page that is mostly marks is reproduced where it stands, one
20
22
  * that is mostly lines is re-set as a document, and the reader records which
21
- * it chose. There is no override — see the note on {@link readingOf}.
23
+ * it chose. There is no override — see the note on {@link readingOf}. A file
24
+ * kept as a page also carries its `flowing` reading, for the one medium that
25
+ * can keep no page: see {@link ReadResult.flowing}.
22
26
  */
23
27
  export declare function readPdf(bytes: Uint8Array, password?: string, filters?: StreamFilters): ReadResult<FlowDoc>;
24
28
  /**
@@ -4,6 +4,7 @@ import { extractPageText } from "./text.js";
4
4
  import { collectPageVectors } from "./vector.js";
5
5
  import { reconstructByLayout } from "./layout.js";
6
6
  import { reconstructTaggedPdf } from "./tagged.js";
7
+ import { WrongPasswordError } from "../core/crypto/password-error.js";
7
8
  //#region src/pdf-reader/reader.ts
8
9
  function sniffPdf(bytes) {
9
10
  const limit = Math.min(bytes.length - 5, 1024);
@@ -41,30 +42,53 @@ function endsLikePdf(bytes) {
41
42
  * @param filters Decoders for `/Filter` names this reader does not implement
42
43
  * (§7.4); see {@link StreamFilters}.
43
44
  * @returns The reconstructed FlowDoc and its accumulated {@link Loss} report.
45
+ * @throws WrongPasswordError when the file is encrypted and the password opens
46
+ * it neither as its user nor as its owner — none given included.
44
47
  *
45
48
  * Which of the two readings a file gets is the FILE's to decide and no
46
49
  * caller's: a page that is mostly marks is reproduced where it stands, one
47
50
  * that is mostly lines is re-set as a document, and the reader records which
48
- * it chose. There is no override — see the note on {@link readingOf}.
51
+ * it chose. There is no override — see the note on {@link readingOf}. A file
52
+ * kept as a page also carries its `flowing` reading, for the one medium that
53
+ * can keep no page: see {@link ReadResult.flowing}.
49
54
  */
50
55
  function readPdf(bytes, password = "", filters = {}) {
51
56
  const file = PdfFile.parse(bytes, password, filters);
57
+ if (file.passwordRefused) throw new WrongPasswordError(password === "" ? "This PDF needs a password to open — pass it as `password`" : "The password opens this PDF neither as its user nor as its owner");
58
+ const { reading, forText } = readingOf(file);
59
+ const read = readAs(file, reading);
60
+ if (!forText) return read;
61
+ return {
62
+ ...read,
63
+ flowing: () => readAs(PdfFile.parse(bytes, password, filters), "flow", true)
64
+ };
65
+ }
66
+ /**
67
+ * The file read the one way: the losses every reading reports, the reading's
68
+ * own, and its reconstruction.
69
+ *
70
+ * @param file The parsed file.
71
+ * @param reading Whether the words re-set as a document or stand where drawn.
72
+ * @param reflowed Whether this is the flowing reading of a file the reader
73
+ * keeps as a page, for a medium that places nothing.
74
+ * @returns The reconstructed FlowDoc and its losses.
75
+ */
76
+ function readAs(file, reading, reflowed = false) {
52
77
  const losses = [];
53
78
  if (file.encryptionUnsupported) losses.push({
54
79
  severity: "dropped",
55
80
  feature: FEATURES.text,
56
- detail: "encrypted PDF — the user password was missing or incorrect, or the handler is unsupported"
81
+ detail: "encrypted PDF — its security handler is not supported, or its /Encrypt dictionary cannot be read, so its content cannot be decrypted"
57
82
  });
58
83
  for (const name of file.unknownFilters) losses.push({
59
84
  severity: "dropped",
60
85
  feature: FEATURES.text,
61
86
  detail: `PDF stream filter /${name} is not supported; streams using it are unreadable`
62
87
  });
63
- const reading = readingOf(file);
64
88
  losses.push({
65
89
  severity: "degraded",
66
90
  feature: FEATURES.text,
67
- detail: reading === "positional" ? "PDF read as a PAGE (placed): its artwork outweighs its prose, so every line stands where its glyphs stand" : "PDF read as a DOCUMENT (flowing): its prose outweighs its artwork, so the words re-set and the artwork takes its turn in reading order"
91
+ detail: reflowed ? "PDF read as a DOCUMENT (flowing) for a medium that keeps only the text: the file is kept as a PAGE elsewhere for its drawn letters or its rules, which only a page can show where they stand" : reading === "positional" ? "PDF read as a PAGE (placed): its artwork outweighs its prose, so every line stands where its glyphs stand" : "PDF read as a DOCUMENT (flowing): its prose outweighs its artwork, so the words re-set and the artwork takes its turn in reading order"
68
92
  });
69
93
  const tagged = reading === "positional" ? void 0 : reconstructTaggedPdf(file);
70
94
  const reconstruction = tagged ?? reconstructByLayout(file, reading);
@@ -94,7 +118,12 @@ function readPdf(bytes, password = "", filters = {}) {
94
118
  * folded into a report does not make the report a plan.
95
119
  *
96
120
  * A threshold is a guess, and this one is stated rather than hidden: the reader
97
- * records which reading it took and why, and the caller can name the other.
121
+ * records which reading it took and why.
122
+ *
123
+ * A glyph the file names no character for is drawn, and counted as a mark like
124
+ * any other path — it IS one, for every medium that has to place it; so is a
125
+ * rule. Whether the page was a page for those alone is said as well: see
126
+ * `forText`.
98
127
  */
99
128
  function readingOf(file) {
100
129
  /**
@@ -112,14 +141,28 @@ function readingOf(file) {
112
141
  const DRAWN = 2;
113
142
  /** Below this many runs, an angle is a stamp or a watermark and not the page. */
114
143
  const ENOUGH_TURNED = 8;
144
+ /**
145
+ * How many rules a page may have for each mark of other artwork and still be
146
+ * a ruled document: an estimate shades its heading row and its total, and a
147
+ * dozen shaded cells under eighty rules are a table's, not a drawing's.
148
+ */
149
+ const RULES_PER_MARK = 4;
115
150
  const ratios = [];
151
+ /** The same, the drawn letters left out. */
152
+ const artwork = [];
153
+ const ratioOf = (marks, lines) => marks < ENOUGH_MARKS ? 0 : lines > 0 ? marks / lines : DRAWN;
116
154
  for (const page of file.pages()) {
117
155
  let marks = 0;
156
+ let letters = 0;
157
+ let rules = 0;
118
158
  let lines = 0;
119
159
  let turned = 0;
120
160
  let runs = 0;
121
161
  try {
122
- marks = collectPageVectors(file, page, []).vectors.length;
162
+ const vectors = collectPageVectors(file, page, []).vectors;
163
+ marks = vectors.length;
164
+ letters = vectors.filter((v) => v.glyph === true).length;
165
+ rules = vectors.filter(isRule).length;
123
166
  const ys = /* @__PURE__ */ new Set();
124
167
  for (const run of extractPageText(file, page)) {
125
168
  ys.add(Math.round(run.y));
@@ -132,17 +175,41 @@ function readingOf(file) {
132
175
  }
133
176
  if (runs >= ENOUGH_TURNED && turned > runs * .5) {
134
177
  ratios.push(DRAWN);
178
+ artwork.push(DRAWN);
135
179
  continue;
136
180
  }
137
- if (marks < ENOUGH_MARKS) {
138
- ratios.push(0);
139
- continue;
140
- }
141
- ratios.push(lines > 0 ? marks / lines : DRAWN);
181
+ ratios.push(ratioOf(marks, lines));
182
+ const ruled = lines > 0 && marks - letters - rules < Math.max(ENOUGH_MARKS, rules / RULES_PER_MARK);
183
+ artwork.push(ruled ? 0 : ratioOf(marks - letters, lines));
142
184
  }
143
- if (ratios.length === 0) return "flow";
144
- const sorted = [...ratios].sort((a, b) => a - b);
145
- return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= DRAWN ? "positional" : "flow";
185
+ const drawn = (rs) => {
186
+ const sorted = [...rs].sort((a, b) => a - b);
187
+ return (sorted[Math.floor(sorted.length / 2)] ?? 0) >= DRAWN;
188
+ };
189
+ if (!drawn(ratios)) return {
190
+ reading: "flow",
191
+ forText: false
192
+ };
193
+ return {
194
+ reading: "positional",
195
+ forText: !drawn(artwork)
196
+ };
197
+ }
198
+ /** Thinner than this a straight mark is a line, whatever it was drawn as. */
199
+ var RULE_THICKNESS = 2.5;
200
+ /** …and it is a rule rather than a dot or a tick from this long. */
201
+ var RULE_LENGTH = 4;
202
+ /**
203
+ * Whether a painted path is a RULE: straight, along the page, a line thin
204
+ * enough to be one — stroked as a line or filled as a hairline box, which is
205
+ * how many producers draw a table's grid.
206
+ */
207
+ function isRule(v) {
208
+ if (v.glyph === true || v.gradient !== void 0) return false;
209
+ if (v.segs.some((seg) => seg.op === "cubic")) return false;
210
+ const w = v.maxX - v.minX;
211
+ const h = v.maxY - v.minY;
212
+ return Math.min(w, h) <= RULE_THICKNESS && Math.max(w, h) >= RULE_LENGTH;
146
213
  }
147
214
  /**
148
215
  * The `pdfReader` adapter: a {@link DocumentReader} that sniffs the `%PDF-`
@@ -40,7 +40,7 @@ function parseSlideShapes(container, ctx = {}, transform = IDENTITY_TRANSFORM, s
40
40
  const out = [];
41
41
  for (const child of poChildren(container)) {
42
42
  if (skipPlaceholders && isPlaceholder(child)) continue;
43
- if (poIs(child, "p:sp")) {
43
+ if (poIs(child, "p:sp") || poIs(child, "p:cxnSp")) {
44
44
  const shape = parseSp(child, ctx, transform);
45
45
  if (shape) out.push({
46
46
  kind: "shape",
@@ -89,6 +89,15 @@ function pictureAsShape(pic) {
89
89
  const nv = poChildren(pic).find((c) => poIs(c, "p:nvPicPr"));
90
90
  return { "p:sp": [...nv ? [{ "p:nvSpPr": poChildren(nv) }] : [], { "p:spPr": [{ "a:blipFill": poChildren(blipFill) }, ...poChildren(spPr)] }] };
91
91
  }
92
+ /** Whether a shape properties node has an `a:ln` that states its own fill. */
93
+ function statesLineFill(spPr) {
94
+ const ln = poChildren(spPr).find((c) => poIs(c, "a:ln"));
95
+ return ln !== void 0 && poChildren(ln).some((c) => poIs(c, "a:noFill") || poIs(c, "a:solidFill") || poIs(c, "a:gradFill") || poIs(c, "a:pattFill"));
96
+ }
97
+ /** A line's stated properties, ready to spread over the ones it inherits. */
98
+ function definedProps(line) {
99
+ return Object.fromEntries(Object.entries(line).filter(([, v]) => v !== void 0));
100
+ }
92
101
  /**
93
102
  * Whether a shape is a placeholder — `p:ph` in its OWN non-visual properties.
94
103
  * Read as "anywhere below", a group holding one would take the whole group
@@ -169,12 +178,18 @@ function parseSp(sp, ctx, transform) {
169
178
  const styled = withStatedDirection(resolved, spPr);
170
179
  const lineFrom = from((n) => poChildren(n).some((c) => poIs(c, "a:ln")));
171
180
  let line = lineFrom ? parseLine(lineFrom, colors) : void 0;
172
- if (style) {
173
- const fromStyle = styleRefLine(style, colors, ctx.themeLineWidths);
174
- if (fromStyle) line = line ? {
175
- ...fromStyle,
176
- ...Object.fromEntries(Object.entries(line).filter(([, v]) => v !== void 0))
177
- } : fromStyle;
181
+ const fromStyle = style ? styleRefLine(style, colors, ctx.themeLineWidths) : void 0;
182
+ if (fromStyle) line = line ? {
183
+ ...fromStyle,
184
+ ...definedProps(line)
185
+ } : fromStyle;
186
+ else if (line && lineFrom && !statesLineFill(lineFrom)) {
187
+ const lentLine = protos.find(statesLineFill);
188
+ const lentProps = lentLine ? parseLine(lentLine, colors) : void 0;
189
+ line = lentProps ? {
190
+ ...lentProps,
191
+ ...definedProps(line)
192
+ } : void 0;
178
193
  }
179
194
  const shadowFrom = from((n) => poChildren(n).some((c) => poIs(c, "a:effectLst")));
180
195
  const shadow = shadowFrom ? parseShadow(shadowFrom, colors) : void 0;
@@ -31,8 +31,9 @@ export declare function parsePh(sp: PoNode): PlaceholderRef | undefined;
31
31
  /**
32
32
  * An xfrm node (`a:off` + `a:ext` children) → the EMU box. Works for both
33
33
  * `a:xfrm` (in `p:spPr`) and `p:xfrm` (on a `p:graphicFrame`), whose children are
34
- * identical. Returns `undefined` for a missing or degenerate (non-positive)
35
- * extent.
34
+ * identical. Returns `undefined` for a missing or negative extent, or one that is
35
+ * empty both ways. A single zero is a real box: a horizontal line has `cy="0"` and
36
+ * a vertical one `cx="0"`.
36
37
  */
37
38
  export declare function boxFromXfrm(xfrm: PoNode | undefined): ShapeBoxEmu | undefined;
38
39
  /**
@@ -34,8 +34,9 @@ function parsePh(sp) {
34
34
  /**
35
35
  * An xfrm node (`a:off` + `a:ext` children) → the EMU box. Works for both
36
36
  * `a:xfrm` (in `p:spPr`) and `p:xfrm` (on a `p:graphicFrame`), whose children are
37
- * identical. Returns `undefined` for a missing or degenerate (non-positive)
38
- * extent.
37
+ * identical. Returns `undefined` for a missing or negative extent, or one that is
38
+ * empty both ways. A single zero is a real box: a horizontal line has `cy="0"` and
39
+ * a vertical one `cx="0"`.
39
40
  */
40
41
  function boxFromXfrm(xfrm) {
41
42
  if (!xfrm) return void 0;
@@ -43,7 +44,8 @@ function boxFromXfrm(xfrm) {
43
44
  const ext = poChildren(xfrm).find((c) => poIs(c, "a:ext"));
44
45
  const cx = ext ? poIntAttr(ext, "cx") : void 0;
45
46
  const cy = ext ? poIntAttr(ext, "cy") : void 0;
46
- if (cx === void 0 || cy === void 0 || cx <= 0 || cy <= 0) return void 0;
47
+ if (cx === void 0 || cy === void 0 || cx < 0 || cy < 0) return void 0;
48
+ if (cx === 0 && cy === 0) return void 0;
47
49
  return {
48
50
  x: (off ? poIntAttr(off, "x") : void 0) ?? 0,
49
51
  y: (off ? poIntAttr(off, "y") : void 0) ?? 0,
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "reamkit",
3
- "version": "1.33.0",
3
+ "version": "1.33.2",
4
4
  "description": "Ream — convert DOCX, XLSX, PPTX, PDF and legacy DOC, XLS and PPT to PDF, SVG, HTML, Markdown, DOCX and XLSX, built from scratch on the ECMA-376 and ISO 32000 specifications. Parse once, convert anywhere.",
5
5
  "license": "MIT",
6
- "author": "Alex Krassavin <info@reamkit.dev>",
6
+ "author": "Alexandr Krassavin <info@reamkit.dev>",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/alex-krassavin/reamkit.git"
@@ -102,7 +102,7 @@
102
102
  "publint": "^0.3.21",
103
103
  "tsx": "^4.19.0",
104
104
  "typescript": "^5.7.0",
105
- "vite": "8.3.2",
105
+ "vite": "8.3.5",
106
106
  "vitest": "^5.0.3"
107
107
  },
108
108
  "overrides": {