@utilix-tech/sdk 0.75.0 → 0.77.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Full release notes for all Utilix surfaces (browser tools, REST API, SDKs, MCP server) live at [utilix.tech/changelog](https://utilix.tech/changelog). This file tracks just this package.
4
4
 
5
+ ## 0.77.0
6
+ - Add `readPdfOutline` (pdf-outline-extractor): walk a PDF's /Outlines bookmark tree (the Root -> Outlines -> First/Next/First chain) and list every bookmark's title, nesting level, and link target, in document order.
7
+ - Add `scoreSecurityHeaders` (security-headers-scorecard): score a pasted block of raw HTTP response headers against commonly recommended security headers (CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-*), with an overall 0-100 score and A-F letter grade.
8
+
9
+ ## 0.76.0
10
+ - Add `detectWordyPhrases` (wordy-phrase-detector): scan text against a curated table of wordy/redundant phrases ("due to the fact that", "in order to") and flag each occurrence with a suggested concise replacement, an occurrence count, and character offsets.
11
+ - Add `checkOxfordCommaConsistency` (oxford-comma-checker): scan a document for comma-before-and/or usage in three-or-more-item lists and flag documents that mix Oxford comma styles.
12
+
5
13
  ## 0.75.0
6
14
  - Add `verifyWebhookSignature` (webhook-signature-verifier): recompute an HMAC over a raw webhook payload and compare it to a provided signature, supporting the `sha256=` prefix style (Stripe/GitHub-style webhooks) plus raw hex or base64 encoding.
7
15
  - Add `calculateTransferTime` and `calculateRequiredBandwidth` (data-transfer-time-estimator): estimate the theoretical time to transfer a file over a connection of a given speed, or the reverse: the bandwidth required to hit a target time.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/sdk
2
2
 
3
- **702 developer utility functions for Node.js: runs locally, no API key required.**
3
+ **705 developer utility functions for Node.js: runs locally, no API key required.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@utilix-tech/sdk?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@utilix-tech/sdk)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/@utilix-tech/sdk?style=flat-square)](https://www.npmjs.com/package/@utilix-tech/sdk)
@@ -152,7 +152,8 @@ generateEtag("console.log(1)");
152
152
  ```ts
153
153
  import { convertCase, slugify, countWords, generateWords, generateParagraphs,
154
154
  escapeString, htmlToMarkdown, applyOps, detectPassiveVoice, scoreReadability,
155
- detectFillerWords, calculateStringDistance, analyzeSentenceLengths,
155
+ detectFillerWords, detectWordyPhrases, checkOxfordCommaConsistency,
156
+ calculateStringDistance, analyzeSentenceLengths,
156
157
  analyzeKeywordDensity, searchEmoji, getEmojiByShortcode } from "@utilix-tech/sdk/text";
157
158
 
158
159
  convertCase("hello world", "camelCase"); // "helloWorld"
@@ -183,6 +184,14 @@ scoreReadability("The cat sat on the mat. It was a sunny day.");
183
184
  detectFillerWords("At the end of the day, I just really think we should circle back.");
184
185
  // { totalMatches: 4, fillerCount: 2, clicheCount: 2, density: 28.6, matches: [...] }
185
186
 
187
+ // Flag wordy/redundant phrases with a suggested concise replacement
188
+ detectWordyPhrases("Due to the fact that it was late, we decided, in order to save time, to skip the meeting.");
189
+ // { totalMatches: 2, density: 10.5, matches: [{ phrase: "due to the fact that", suggestion: "because", ... }, ...] }
190
+
191
+ // Flag a document that mixes Oxford-comma and non-Oxford-comma lists
192
+ checkOxfordCommaConsistency("I bought apples, bananas, and pears. Then I bought milk, eggs and bread.");
193
+ // { totalLists: 2, withOxfordComma: 1, withoutOxfordComma: 1, consistent: false, instances: [...] }
194
+
186
195
  // Levenshtein edit distance + similarity ratio; pass mode: "damerau" to count a transposition as 1 edit
187
196
  calculateStringDistance("kitten", "sitting");
188
197
  // { distance: 3, similarity: 0.5714, maxLength: 7, mode: "levenshtein" }
@@ -701,6 +710,11 @@ buildRobotsMetaTag({ flags: ["noindex", "nofollow"], name: "googlebot" });
701
710
  // Cookie header byte-size budget: per-cookie and total sizes against RFC 6265's 4096-byte limit
702
711
  calculateCookieSizeBudget("session=abc123; theme=dark; cart=42");
703
712
  // { cookieCount: 3, totalBytes: 35, budgetBytes: 4096, exceedsBudget: false, cookies: [...] }
713
+
714
+ // Score pasted response headers against commonly recommended security headers
715
+ import { scoreSecurityHeaders } from "@utilix-tech/sdk/network";
716
+ scoreSecurityHeaders("Content-Security-Policy: default-src 'self'\nStrict-Transport-Security: max-age=63072000");
717
+ // { score: 22, grade: "F", passCount: 2, warningCount: 0, missingCount: 7, totalChecks: 9, checks: [...] }
704
718
  ```
705
719
 
706
720
  ---
@@ -901,10 +915,10 @@ analyzeString("café"); // { length: 4, codePoints: [...], ... }
901
915
 
902
916
  ---
903
917
 
904
- ### `/media`: Image Header Parsing, EXIF, PDF Metadata & Page Dimensions, WAV, ID3, ICO, FLAC, Video, GIF & MIDI Metadata
918
+ ### `/media`: Image Header Parsing, EXIF, PDF Metadata, Page Dimensions & Outlines, WAV, ID3, ICO, FLAC, Video, GIF & MIDI Metadata
905
919
 
906
920
  ```ts
907
- import { readImageInfo, readExifData, readPdfMetadata, readPdfPageDimensions, readWavInfo, readId3Tags, readIcoInfo, readFlacInfo, readVideoInfo, inspectGifFrames, readMidiInfo } from "@utilix-tech/sdk/media";
921
+ import { readImageInfo, readExifData, readPdfMetadata, readPdfPageDimensions, readPdfOutline, readWavInfo, readId3Tags, readIcoInfo, readFlacInfo, readVideoInfo, inspectGifFrames, readMidiInfo } from "@utilix-tech/sdk/media";
908
922
 
909
923
  // Reads format, dimensions, bit depth, and alpha channel straight from
910
924
  // file bytes: no decoding, no canvas, no image library.
@@ -930,6 +944,12 @@ readPdfMetadata(new Uint8Array(pdfBytes));
930
944
  readPdfPageDimensions(new Uint8Array(pdfBytes));
931
945
  // { version: "1.7", pageCount: 12, pages: [{ pageNumber: 1, widthPt: 612, heightPt: 792, rotation: 0, orientation: "portrait", paperSize: "Letter" }, ...] }
932
946
 
947
+ // Walks a PDF's /Outlines bookmark tree (Root -> Outlines -> First/Next/
948
+ // First chain) to list every bookmark's title, nesting level, and link
949
+ // target, in document order: no PDF rendering library required.
950
+ readPdfOutline(new Uint8Array(pdfBytes));
951
+ // { version: "1.7", bookmarkCount: 3, bookmarks: [{ title: "Chapter 1", level: 0, destType: "page", target: "3 0 R" }, ...] }
952
+
933
953
  // Reads sample rate, channels, bit depth, and duration from a WAV file's
934
954
  // RIFF/fmt/data chunk headers: no audio library required.
935
955
  const wavBytes = await fs.promises.readFile("recording.wav");
@@ -72,6 +72,7 @@ __export(network_exports, {
72
72
  parseVaryHeader: () => parseVaryHeader,
73
73
  parseXContentTypeOptionsHeader: () => parseXContentTypeOptionsHeader,
74
74
  parseXRobotsTag: () => parseXRobotsTag,
75
+ scoreSecurityHeaders: () => scoreSecurityHeaders,
75
76
  searchHeaders: () => searchHeaders,
76
77
  searchStatusCodes: () => searchStatusCodes,
77
78
  splitSitemap: () => splitSitemap,
@@ -3511,5 +3512,122 @@ function calculateCookieSizeBudget(cookieHeader, budgetBytes = DEFAULT_COOKIE_BU
3511
3512
  exceedsBudget: totalBytes > budgetBytes
3512
3513
  };
3513
3514
  }
3515
+ function parseRawHeaderBlock(raw) {
3516
+ const map = /* @__PURE__ */ new Map();
3517
+ for (const line of raw.split(/\r?\n/)) {
3518
+ const trimmed = line.trim();
3519
+ if (!trimmed) continue;
3520
+ const idx = trimmed.indexOf(":");
3521
+ if (idx === -1) continue;
3522
+ const name = trimmed.slice(0, idx).trim().toLowerCase();
3523
+ const value = trimmed.slice(idx + 1).trim();
3524
+ if (!map.has(name)) map.set(name, value);
3525
+ }
3526
+ return map;
3527
+ }
3528
+ var SECURITY_HEADER_CHECKS = [
3529
+ {
3530
+ header: "Content-Security-Policy",
3531
+ evaluate: (v) => {
3532
+ if (!v) return { status: "missing", note: "Without a CSP, an injected script runs with full page privileges." };
3533
+ if (/unsafe-inline|unsafe-eval/i.test(v)) return { status: "warning", note: "Contains unsafe-inline or unsafe-eval, which weakens XSS protection." };
3534
+ return { status: "pass", note: "Present. Use the CSP Header Builder tool to validate its directives in depth." };
3535
+ }
3536
+ },
3537
+ {
3538
+ header: "Strict-Transport-Security",
3539
+ evaluate: (v) => {
3540
+ if (!v) return { status: "missing", note: "Without HSTS, a user's first visit (and any later downgrade) can be sent over plain HTTP." };
3541
+ const maxAgeMatch = v.match(/max-age\s*=\s*(\d+)/i);
3542
+ if (!maxAgeMatch) return { status: "warning", note: "Present but missing a max-age directive." };
3543
+ const maxAge = Number(maxAgeMatch[1]);
3544
+ if (maxAge < 31536e3) return { status: "warning", note: "max-age is below the recommended 1 year (31536000 seconds)." };
3545
+ return { status: "pass", note: "Present with a max-age of at least 1 year." };
3546
+ }
3547
+ },
3548
+ {
3549
+ header: "X-Content-Type-Options",
3550
+ evaluate: (v) => {
3551
+ if (!v) return { status: "missing", note: "Without this header, browsers may MIME-sniff a response into an unintended content type." };
3552
+ if (v.trim().toLowerCase() !== "nosniff") return { status: "warning", note: `"nosniff" is the only valid value; this value won't take effect.` };
3553
+ return { status: "pass", note: "Set to the only valid value, nosniff." };
3554
+ }
3555
+ },
3556
+ {
3557
+ header: "X-Frame-Options",
3558
+ evaluate: (v) => {
3559
+ if (!v) return { status: "missing", note: "Without this (or a CSP frame-ancestors directive), the page can be framed by another site for clickjacking." };
3560
+ const upper = v.trim().toUpperCase();
3561
+ if (upper === "DENY" || upper === "SAMEORIGIN") return { status: "pass", note: `Framing restricted (${upper}).` };
3562
+ if (upper.startsWith("ALLOW-FROM")) return { status: "warning", note: "ALLOW-FROM is deprecated and ignored by most modern browsers." };
3563
+ return { status: "warning", note: "Unrecognized value." };
3564
+ }
3565
+ },
3566
+ {
3567
+ header: "Referrer-Policy",
3568
+ evaluate: (v) => {
3569
+ if (!v) return { status: "missing", note: "Without this header, the browser's default referrer behavior applies, which can leak full URLs to third parties." };
3570
+ if (v.trim().toLowerCase() === "unsafe-url") return { status: "warning", note: "unsafe-url leaks the full URL, including path and query string, to every destination." };
3571
+ return { status: "pass", note: "Present." };
3572
+ }
3573
+ },
3574
+ {
3575
+ header: "Permissions-Policy",
3576
+ evaluate: (v) => {
3577
+ if (!v) return { status: "missing", note: "Without this header, embedded content can use powerful browser features (camera, geolocation, etc.) unless restricted another way." };
3578
+ return { status: "pass", note: "Present." };
3579
+ }
3580
+ },
3581
+ {
3582
+ header: "Cross-Origin-Opener-Policy",
3583
+ evaluate: (v) => {
3584
+ if (!v) return { status: "missing", note: "Without COOP, this page can be put in the same browsing context group as a malicious opener window." };
3585
+ const lower = v.trim().toLowerCase();
3586
+ if (lower === "same-origin") return { status: "pass", note: "Set to same-origin, the strongest isolation value." };
3587
+ return { status: "warning", note: `Set to "${v.trim()}", which is weaker than same-origin.` };
3588
+ }
3589
+ },
3590
+ {
3591
+ header: "Cross-Origin-Embedder-Policy",
3592
+ evaluate: (v) => {
3593
+ if (!v) return { status: "missing", note: "Required alongside COOP for cross-origin isolation (e.g. to use SharedArrayBuffer)." };
3594
+ const lower = v.trim().toLowerCase();
3595
+ if (lower === "require-corp" || lower === "credentialless") return { status: "pass", note: `Set to ${lower}.` };
3596
+ return { status: "warning", note: `Unrecognized value "${v.trim()}".` };
3597
+ }
3598
+ },
3599
+ {
3600
+ header: "Cross-Origin-Resource-Policy",
3601
+ evaluate: (v) => {
3602
+ if (!v) return { status: "missing", note: "Without CORP, this resource can be loaded cross-origin by any site that knows its URL." };
3603
+ const lower = v.trim().toLowerCase();
3604
+ if (lower === "same-origin" || lower === "same-site") return { status: "pass", note: `Set to ${lower}.` };
3605
+ if (lower === "cross-origin") return { status: "warning", note: "Set to cross-origin, which allows any site to load this resource." };
3606
+ return { status: "warning", note: `Unrecognized value "${v.trim()}".` };
3607
+ }
3608
+ }
3609
+ ];
3610
+ function scoreSecurityHeaders(rawHeaders) {
3611
+ if (typeof rawHeaders !== "string" || !rawHeaders.trim()) {
3612
+ return { error: 'Paste one or more response headers, one per line in "Name: value" form' };
3613
+ }
3614
+ const headerMap = parseRawHeaderBlock(rawHeaders);
3615
+ if (headerMap.size === 0) {
3616
+ return { error: 'Could not parse any "Name: value" header lines from the input' };
3617
+ }
3618
+ const checks = SECURITY_HEADER_CHECKS.map((def) => {
3619
+ const value = headerMap.get(def.header.toLowerCase()) ?? null;
3620
+ const { status, note } = def.evaluate(value);
3621
+ return { header: def.header, status, value, note };
3622
+ });
3623
+ const passCount = checks.filter((c) => c.status === "pass").length;
3624
+ const warningCount = checks.filter((c) => c.status === "warning").length;
3625
+ const missingCount = checks.filter((c) => c.status === "missing").length;
3626
+ const totalChecks = checks.length;
3627
+ const points = passCount + warningCount * 0.5;
3628
+ const score = Math.round(points / totalChecks * 100);
3629
+ const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
3630
+ return { checks, score, grade, passCount, warningCount, missingCount, totalChecks };
3631
+ }
3514
3632
 
3515
- export { CLIENT_HINTS, DNS_RECORD_TYPES, HTTP_HEADERS, LINK_REL_VALUES, PERMISSIONS_POLICY_FEATURES, REFERRER_POLICY_VALUES, STATUS_CODES, X_CONTENT_TYPE_OPTIONS_HEADER, analyzeEmailHeaders, assessContentTypeRisk, buildAcceptLanguage, buildCacheControl, buildClientHints, buildContentDisposition, buildDnsQueryUrl, buildGeoUrl, buildHsts, buildLinkHeader, buildMapUrl, buildPermissionsPolicy, buildPreferHeader, buildPriorityHeader, buildRangeHeader, buildReferrerPolicy, buildRetryAfterHeader, buildRobotsMetaTag, buildVaryHeader, buildXContentTypeOptionsHeader, buildXRobotsTag, calculateCookieSizeBudget, checkSetCookieHeader, countryCodeToFlag, extractMultipartBoundary, formatCoordinates, formatTtl, getByCategory, getHeader, getHeadersByCategory, getStatusByCategory, getStatusCode, isClientError, isServerError, isSuccess, isValidIp, isValidIpv4, isValidIpv6, listCloseCodes, listOpcodes, lookupCloseCode, lookupOpcode, network_exports, parseAcceptLanguage, parseClientHintHeaders, parseContentDisposition, parseContentRangeHeader, parseDnsResponse, parseGeoResponse, parseHar, parseHsts, parseIpv6Address, parseMultipartFormData, parsePermissionsPolicy, parsePreferHeader, parsePriorityHeader, parseRetryAfterHeader, parseRobotsMetaTag, parseUserAgent, parseVaryHeader, parseXContentTypeOptionsHeader, parseXRobotsTag, searchHeaders, searchStatusCodes, splitSitemap, statusCodeToText, typeNumberToLabel, validateDomain, validateHeaderName, validateHeaderValue, validateManifest, validateRobotsTxt, validateSitemap };
3633
+ export { CLIENT_HINTS, DNS_RECORD_TYPES, HTTP_HEADERS, LINK_REL_VALUES, PERMISSIONS_POLICY_FEATURES, REFERRER_POLICY_VALUES, STATUS_CODES, X_CONTENT_TYPE_OPTIONS_HEADER, analyzeEmailHeaders, assessContentTypeRisk, buildAcceptLanguage, buildCacheControl, buildClientHints, buildContentDisposition, buildDnsQueryUrl, buildGeoUrl, buildHsts, buildLinkHeader, buildMapUrl, buildPermissionsPolicy, buildPreferHeader, buildPriorityHeader, buildRangeHeader, buildReferrerPolicy, buildRetryAfterHeader, buildRobotsMetaTag, buildVaryHeader, buildXContentTypeOptionsHeader, buildXRobotsTag, calculateCookieSizeBudget, checkSetCookieHeader, countryCodeToFlag, extractMultipartBoundary, formatCoordinates, formatTtl, getByCategory, getHeader, getHeadersByCategory, getStatusByCategory, getStatusCode, isClientError, isServerError, isSuccess, isValidIp, isValidIpv4, isValidIpv6, listCloseCodes, listOpcodes, lookupCloseCode, lookupOpcode, network_exports, parseAcceptLanguage, parseClientHintHeaders, parseContentDisposition, parseContentRangeHeader, parseDnsResponse, parseGeoResponse, parseHar, parseHsts, parseIpv6Address, parseMultipartFormData, parsePermissionsPolicy, parsePreferHeader, parsePriorityHeader, parseRetryAfterHeader, parseRobotsMetaTag, parseUserAgent, parseVaryHeader, parseXContentTypeOptionsHeader, parseXRobotsTag, scoreSecurityHeaders, searchHeaders, searchStatusCodes, splitSitemap, statusCodeToText, typeNumberToLabel, validateDomain, validateHeaderName, validateHeaderValue, validateManifest, validateRobotsTxt, validateSitemap };
@@ -18,6 +18,7 @@ __export(text_exports, {
18
18
  applyOps: () => applyOps,
19
19
  calculateStringDistance: () => calculateStringDistance,
20
20
  centerText: () => centerText,
21
+ checkOxfordCommaConsistency: () => checkOxfordCommaConsistency,
21
22
  convertAll: () => convertAll,
22
23
  convertCase: () => convertCase,
23
24
  countWords: () => countWords,
@@ -26,6 +27,7 @@ __export(text_exports, {
26
27
  detectDelimiter: () => detectDelimiter,
27
28
  detectFillerWords: () => detectFillerWords,
28
29
  detectPassiveVoice: () => detectPassiveVoice,
30
+ detectWordyPhrases: () => detectWordyPhrases,
29
31
  diffLines: () => diffLines,
30
32
  diffLinesSimple: () => diffLinesSimple,
31
33
  diffStats: () => diffStats,
@@ -3095,5 +3097,99 @@ function getEmojiByCategory(category) {
3095
3097
  function getEmojiCategories() {
3096
3098
  return Array.from(new Set(EMOJI_TABLE.map((e) => e.category)));
3097
3099
  }
3100
+ var WORDY_PHRASES = [
3101
+ { phrase: "due to the fact that", suggestion: "because" },
3102
+ { phrase: "in spite of the fact that", suggestion: "although" },
3103
+ { phrase: "despite the fact that", suggestion: "although" },
3104
+ { phrase: "in order to", suggestion: "to" },
3105
+ { phrase: "for the purpose of", suggestion: "for" },
3106
+ { phrase: "in the event that", suggestion: "if" },
3107
+ { phrase: "in the near future", suggestion: "soon" },
3108
+ { phrase: "at this point in time", suggestion: "now" },
3109
+ { phrase: "at the present time", suggestion: "now" },
3110
+ { phrase: "on a daily basis", suggestion: "daily" },
3111
+ { phrase: "on a regular basis", suggestion: "regularly" },
3112
+ { phrase: "in a timely manner", suggestion: "promptly" },
3113
+ { phrase: "with regard to", suggestion: "regarding" },
3114
+ { phrase: "with respect to", suggestion: "regarding" },
3115
+ { phrase: "in regards to", suggestion: "regarding" },
3116
+ { phrase: "with the exception of", suggestion: "except" },
3117
+ { phrase: "in close proximity to", suggestion: "near" },
3118
+ { phrase: "take into consideration", suggestion: "consider" },
3119
+ { phrase: "give consideration to", suggestion: "consider" },
3120
+ { phrase: "make a decision", suggestion: "decide" },
3121
+ { phrase: "is able to", suggestion: "can" },
3122
+ { phrase: "are able to", suggestion: "can" },
3123
+ { phrase: "a large number of", suggestion: "many" },
3124
+ { phrase: "a majority of", suggestion: "most" },
3125
+ { phrase: "each and every", suggestion: "each" },
3126
+ { phrase: "first and foremost", suggestion: "first" },
3127
+ { phrase: "last but not least", suggestion: "finally" },
3128
+ { phrase: "null and void", suggestion: "void" },
3129
+ { phrase: "whether or not", suggestion: "whether" },
3130
+ { phrase: "the reason why", suggestion: "the reason" },
3131
+ { phrase: "end result", suggestion: "result" },
3132
+ { phrase: "future plans", suggestion: "plans" },
3133
+ { phrase: "past history", suggestion: "history" },
3134
+ { phrase: "basic fundamentals", suggestion: "fundamentals" },
3135
+ { phrase: "absolutely essential", suggestion: "essential" },
3136
+ { phrase: "it is important to note that", suggestion: "(often removable)" },
3137
+ { phrase: "as a matter of fact", suggestion: "in fact" },
3138
+ { phrase: "in the process of", suggestion: "(often removable)" }
3139
+ ];
3140
+ function _wordyRegex(phrase) {
3141
+ const tokens = phrase.split(/\s+/).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
3142
+ return new RegExp("\\b" + tokens.join("\\s+") + "\\b", "gi");
3143
+ }
3144
+ function detectWordyPhrases(text) {
3145
+ const totalWords = (text.match(/[A-Za-z0-9']+/g) ?? []).length;
3146
+ const matches = [];
3147
+ for (const entry of WORDY_PHRASES) {
3148
+ const re = _wordyRegex(entry.phrase);
3149
+ const indices = [];
3150
+ let m;
3151
+ while ((m = re.exec(text)) !== null) {
3152
+ indices.push(m.index);
3153
+ if (m.index === re.lastIndex) re.lastIndex++;
3154
+ }
3155
+ if (indices.length > 0) {
3156
+ matches.push({ phrase: entry.phrase, suggestion: entry.suggestion, count: indices.length, indices });
3157
+ }
3158
+ }
3159
+ matches.sort((a, b) => b.count - a.count || a.phrase.localeCompare(b.phrase));
3160
+ const totalMatches = matches.reduce((s, m) => s + m.count, 0);
3161
+ const density = totalWords > 0 ? Math.round(totalMatches / totalWords * 1e3) / 10 : 0;
3162
+ return { totalWords, totalMatches, density, matches };
3163
+ }
3164
+ var _OXFORD_CONJ_RE = /(,)?\s+\b(?:and|or)\b\s+/gi;
3165
+ function checkOxfordCommaConsistency(text) {
3166
+ const instances = [];
3167
+ _OXFORD_CONJ_RE.lastIndex = 0;
3168
+ let m;
3169
+ while ((m = _OXFORD_CONJ_RE.exec(text)) !== null) {
3170
+ const matchStart = m.index;
3171
+ const hasCommaBefore = !!m[1];
3172
+ let clauseStart = -1;
3173
+ for (const ch of [".", "!", "?", "\n"]) {
3174
+ const idx = text.lastIndexOf(ch, matchStart - 1);
3175
+ if (idx > clauseStart) clauseStart = idx;
3176
+ }
3177
+ clauseStart += 1;
3178
+ const clause = text.slice(clauseStart, matchStart);
3179
+ const commaCount = (clause.match(/,/g) ?? []).length;
3180
+ if (commaCount < 1) continue;
3181
+ const snippetStart = Math.max(clauseStart, matchStart - 60);
3182
+ const afterConjEnd = _OXFORD_CONJ_RE.lastIndex;
3183
+ const tail = text.slice(afterConjEnd);
3184
+ const boundaryOffset = tail.search(/[.!?\n,]/);
3185
+ const snippetEnd = boundaryOffset === -1 ? Math.min(text.length, afterConjEnd + 40) : afterConjEnd + boundaryOffset;
3186
+ const snippet = text.slice(snippetStart, snippetEnd).trim();
3187
+ instances.push({ snippet, hasOxfordComma: hasCommaBefore, index: matchStart });
3188
+ }
3189
+ const withOxfordComma = instances.filter((i) => i.hasOxfordComma).length;
3190
+ const withoutOxfordComma = instances.length - withOxfordComma;
3191
+ const consistent = withOxfordComma === 0 || withoutOxfordComma === 0;
3192
+ return { totalLists: instances.length, withOxfordComma, withoutOxfordComma, consistent, instances };
3193
+ }
3098
3194
 
3099
- export { CASE_FORMATS, DEFAULT_CONVERT_OPTIONS, EMOJI_TABLE, ESCAPE_MODES, LINE_OPS, MORSE_CODE, addBorder, analyzeKeywordDensity, analyzeSentenceLengths, analyzeText, applyOp, applyOps, calculateStringDistance, centerText, convertAll, convertCase, countWords, csvToHtml, csvToMarkdown, detectDelimiter, detectFillerWords, detectPassiveVoice, diffLines, diffLinesSimple, diffStats, escapeAll, escapeString, extractHeadings, formatReadingTime, generate, generateDiff, generateHtmlTable, generateMarkdownTable, generateParagraphs, generateSentences, generateWords, getAvailableFonts, getEmojiByCategory, getEmojiByChar, getEmojiByShortcode, getEmojiCategories, getMorseForChar, getMorseTiming, htmlToMarkdown, isValidMorse, markdownToHtml, morseToText, numberToOrdinal, numberToRoman, numberToWords, ordinalWords, parseDiff, parseTableData, readabilityScore, renderMarkdown, romanToNumber, scoreReadability, searchEmoji, sentenceCount, slugify, slugifyAll, syllableCount, textToAscii, textToMorse, text_exports, unescapeString, wordFrequency };
3195
+ export { CASE_FORMATS, DEFAULT_CONVERT_OPTIONS, EMOJI_TABLE, ESCAPE_MODES, LINE_OPS, MORSE_CODE, addBorder, analyzeKeywordDensity, analyzeSentenceLengths, analyzeText, applyOp, applyOps, calculateStringDistance, centerText, checkOxfordCommaConsistency, convertAll, convertCase, countWords, csvToHtml, csvToMarkdown, detectDelimiter, detectFillerWords, detectPassiveVoice, detectWordyPhrases, diffLines, diffLinesSimple, diffStats, escapeAll, escapeString, extractHeadings, formatReadingTime, generate, generateDiff, generateHtmlTable, generateMarkdownTable, generateParagraphs, generateSentences, generateWords, getAvailableFonts, getEmojiByCategory, getEmojiByChar, getEmojiByShortcode, getEmojiCategories, getMorseForChar, getMorseTiming, htmlToMarkdown, isValidMorse, markdownToHtml, morseToText, numberToOrdinal, numberToRoman, numberToWords, ordinalWords, parseDiff, parseTableData, readabilityScore, renderMarkdown, romanToNumber, scoreReadability, searchEmoji, sentenceCount, slugify, slugifyAll, syllableCount, textToAscii, textToMorse, text_exports, unescapeString, wordFrequency };
@@ -11,6 +11,7 @@ __export(media_exports, {
11
11
  readImageInfo: () => readImageInfo,
12
12
  readMidiInfo: () => readMidiInfo,
13
13
  readPdfMetadata: () => readPdfMetadata,
14
+ readPdfOutline: () => readPdfOutline,
14
15
  readPdfPageDimensions: () => readPdfPageDimensions,
15
16
  readVideoInfo: () => readVideoInfo,
16
17
  readWavInfo: () => readWavInfo
@@ -581,6 +582,74 @@ function readPdfPageDimensions(bytes) {
581
582
  if (out.length === 0) return { error: "Could not locate any page objects in this PDF" };
582
583
  return { version, pageCount: out.length, pages: out };
583
584
  }
585
+ function parsePdfOutlineDest(dict) {
586
+ const directRef = dict.match(/\/Dest\s*\[\s*(\d+)\s+(\d+)\s+R/);
587
+ if (directRef) return { destType: "page", target: `${directRef[1]} ${directRef[2]} R` };
588
+ const directName = dict.match(/\/Dest\s*\/([^\s/()<>[\]]+)/);
589
+ if (directName) return { destType: "named", target: directName[1] };
590
+ const directString = dict.match(/\/Dest\s*\(((?:\\.|[^\\()])*)\)/);
591
+ if (directString) return { destType: "named", target: decodePdfString(directString[1], false) };
592
+ const actionUri = dict.match(/\/S\s*\/URI[\s\S]*?\/URI\s*\(((?:\\.|[^\\()])*)\)/);
593
+ if (actionUri) return { destType: "uri", target: decodePdfString(actionUri[1], false) };
594
+ const actionGotoRef = dict.match(/\/S\s*\/GoTo[\s\S]*?\/D\s*\[\s*(\d+)\s+(\d+)\s+R/);
595
+ if (actionGotoRef) return { destType: "page", target: `${actionGotoRef[1]} ${actionGotoRef[2]} R` };
596
+ const actionGotoName = dict.match(/\/S\s*\/GoTo[\s\S]*?\/D\s*\/([^\s/()<>[\]]+)/);
597
+ if (actionGotoName) return { destType: "named", target: actionGotoName[1] };
598
+ return { destType: "none", target: null };
599
+ }
600
+ function walkPdfOutlineChain(objMap, startRef, level, visited, out) {
601
+ let ref = startRef;
602
+ while (ref) {
603
+ if (visited.has(ref)) break;
604
+ visited.add(ref);
605
+ const body = objMap.get(ref);
606
+ if (!body) break;
607
+ const titleRaw = extractDictField(body, "Title");
608
+ const { destType, target } = parsePdfOutlineDest(body);
609
+ out.push({ title: titleRaw ?? "(untitled)", level, destType, target });
610
+ const firstMatch = body.match(/\/First\s+(\d+)\s+(\d+)\s+R/);
611
+ if (firstMatch) walkPdfOutlineChain(objMap, `${firstMatch[1]} ${firstMatch[2]}`, level + 1, visited, out);
612
+ const nextMatch = body.match(/\/Next\s+(\d+)\s+(\d+)\s+R/);
613
+ ref = nextMatch ? `${nextMatch[1]} ${nextMatch[2]}` : null;
614
+ }
615
+ }
616
+ function readPdfOutline(bytes) {
617
+ if (bytes.length < 8) return { error: "File too small to be a PDF" };
618
+ const header = bytesToLatin1(bytes.subarray(0, 16));
619
+ const versionMatch = header.match(/%PDF-(\d\.\d)/);
620
+ if (!versionMatch) return { error: "Not a valid PDF file (missing %PDF header)" };
621
+ const version = versionMatch[1];
622
+ const text = bytesToLatin1(bytes);
623
+ const objBlocks = [...text.matchAll(/(\d+)\s+(\d+)\s+obj([\s\S]*?)endobj/g)];
624
+ const objMap = /* @__PURE__ */ new Map();
625
+ for (const b of objBlocks) objMap.set(`${b[1]} ${b[2]}`, b[3]);
626
+ let outlinesBody = null;
627
+ const trailerMatches = [...text.matchAll(/trailer\s*<<([\s\S]*?)>>/g)];
628
+ if (trailerMatches.length > 0) {
629
+ const trailer = trailerMatches[trailerMatches.length - 1][1];
630
+ const rootRef = trailer.match(/\/Root\s+(\d+)\s+(\d+)\s+R/);
631
+ if (rootRef) {
632
+ const catalogBody = objMap.get(`${rootRef[1]} ${rootRef[2]}`);
633
+ const outlinesRef = catalogBody?.match(/\/Outlines\s+(\d+)\s+(\d+)\s+R/);
634
+ if (outlinesRef) outlinesBody = objMap.get(`${outlinesRef[1]} ${outlinesRef[2]}`) ?? null;
635
+ }
636
+ }
637
+ if (!outlinesBody) {
638
+ for (const b of objBlocks) {
639
+ if (/\/Type\s*\/Outlines\b/.test(b[3])) {
640
+ outlinesBody = b[3];
641
+ break;
642
+ }
643
+ }
644
+ }
645
+ if (!outlinesBody) return { error: "No /Outlines bookmark tree found in this PDF" };
646
+ const out = [];
647
+ const firstMatch = outlinesBody.match(/\/First\s+(\d+)\s+(\d+)\s+R/);
648
+ if (firstMatch) {
649
+ walkPdfOutlineChain(objMap, `${firstMatch[1]} ${firstMatch[2]}`, 0, /* @__PURE__ */ new Set(), out);
650
+ }
651
+ return { version, bookmarkCount: out.length, bookmarks: out };
652
+ }
584
653
  var AUDIO_FORMAT_LABELS = {
585
654
  1: "PCM",
586
655
  3: "IEEE float",
@@ -1438,4 +1507,4 @@ function readMidiInfo(bytes) {
1438
1507
  };
1439
1508
  }
1440
1509
 
1441
- export { inspectGifFrames, media_exports, readExifData, readFlacInfo, readIcoInfo, readId3Tags, readImageInfo, readMidiInfo, readPdfMetadata, readPdfPageDimensions, readVideoInfo, readWavInfo };
1510
+ export { inspectGifFrames, media_exports, readExifData, readFlacInfo, readIcoInfo, readId3Tags, readImageInfo, readMidiInfo, readPdfMetadata, readPdfOutline, readPdfPageDimensions, readVideoInfo, readWavInfo };