@framers/agentos-ext-document-export

中文文档
0.3.10发布于 2026/11/11Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 2.4万

Generate PDF, DOCX, PPTX, CSV, and XLSX documents with charts, tables, and themes

原始使用文档
文档目录11 节
  1. Installation
  2. The extension pack
  3. Transcript exports (browser-safe)
  4. Writing an archive entry by entry
  5. Archives (browser-safe)
  6. What an open reads
  7. Names and kinds
  8. What an archive must hold
  9. Reads
  10. Calendar events (browser-safe)
  11. Markdown body text (browser-safe)

Document Export

Generate PDF, DOCX, PPTX, CSV and XLSX documents from an agent's structured content. A second entry, ./transcript, writes a transcript as Markdown, plain text, JSON, CSV, SRT, VTT, DOCX, an HTML fragment or a zip; a third, ./archive, reads a ZIP one entry at a time under bounds; a fourth, ./calendar, writes one event as an iCalendar file and as Google Calendar's template address. The three run in a browser as well as in Node.

Installation

npm install @framers/agentos-ext-document-export

The extension pack

import { createExtensionPack } from '@framers/agentos-ext-document-export';

const pack = createExtensionPack({ options: { workspaceDir: '/srv/agent' } });
ToolWhat it does
document_exportRenders structured content (sections with paragraphs, tables, charts and images) to pdf, docx, pptx, csv or xlsx, saves the file under <workspaceDir>/exports/ and answers its download and preview links
document_suggestReads a reply's length, tables and sections and says whether to offer an export, and in which formats, with no model call

Options: workspaceDir (default: the process's working folder), serverPort (default 3777), publicBaseUrl and priority (default 50). This entry runs in Node: it writes files to disk and renders PDF with pdfkit.

Transcript exports (browser-safe)

@framers/agentos-ext-document-export/transcript writes a transcript with its times, an after-session pack whose lines carry their quotes, and the checks shown beside the lines. It imports nothing but fflate and never touches a file system: each writer answers a string or bytes, and the caller saves them.

import type { TranscriptExport } from '@framers/agentos-ext-document-export/transcript';

const doc: TranscriptExport = {
  title: 'Budget review',
  startedAt: '2026-10-08T14:05:00.000Z',
  durationSeconds: 754,
  tags: ['q3'],
  pack: {
    summary: 'The team agreed the budget.',
    decisions: [{ text: 'Budget grows ten percent.', quotes: [{ seq: 2, text: 'we grow the budget by ten percent' }] }],
    actionItems: [{ text: 'Send the slides.', owner: 'Ann', quotes: [{ seq: 3, text: "I'll send the slides" }] }],
    openQuestions: [],
  },
  checks: [{ seq: 2, label: 'Matches your notes', source: 'Note: Budget', sentence: 'Budget up ten percent.' }],
  turns: [
    { seq: 1, startMs: 0, endMs: 2_000, text: 'Shall we start?', speaker: 'You' },
    { seq: 2, startMs: 65_000, endMs: 69_500, text: 'Yes, we grow the budget by ten percent.', speaker: 'Other' },
    { seq: 3, startMs: 70_000, endMs: null, text: "I'll send the slides." },
  ],
};

Times are milliseconds from the session's start. A quote names its turn by seq, and the writers print that turn's time beside it. A turn's speaker is optional and is printed when present.

WriterAnswers
toMarkdown(doc)Markdown: the title, a line of facts, the summary, the decisions, action items and open questions with their quotes, the transcript with times and speakers, each check under its line, and the notes
toPlainText(doc)The same without Markdown marks
toJson(doc)Every record, with format: 'transcript-export' and v: 1
toHtml(doc)An HTML fragment for a clipboard: the title, the pack with its quotes, the transcript and the notes, each line break written as <br>
actionItemsCsv(doc)The action items as CSV: the item, its owner, its first quote and that quote's time, after a UTF-8 byte order mark
toSrt(doc)SubRip cues, listed by their start and numbered from 1
toVtt(doc)A WebVTT file, its cues listed by their start
toDocx(doc)The bytes of a Word document with headings, paragraphs and quotes, each line break written as <w:br/> and each tab as <w:tab/>
writeArchive(entries, options?), readArchive(bytes, options?)A zip of several files, and its files read back in their order, with folder entries left out and no more than maxInflatedBytes (50 MiB unless you pass another bound) inflated in all. With options (onEntry, signal, mtime), writeArchive answers a promise and writes entry by entry (below)

In the Markdown, plain-text, HTML and Word writers a section with nothing in it is left out: the summary's heading needs a summary, a list its entries, the notes' heading some notes and the transcript's heading a turn, so { ...doc, turns: [] } writes the record without a transcript section.

In these four writers a text keeps its own line breaks, blank lines included. In Markdown and plain text one blank line separates two sections, and the line breaks at the start and end of a text are left out.

One example per writer, on the record above:

import {
  actionItemsCsv,
  readArchive,
  toDocx,
  toHtml,
  toJson,
  toMarkdown,
  toPlainText,
  toSrt,
  toVtt,
  writeArchive,
} from '@framers/agentos-ext-document-export/transcript';

toMarkdown(doc);
// # Budget review
//
// Thu, 08 Oct 2026 14:05:00 GMT | 13 minutes | q3
//
// ## Summary
//
// The team agreed the budget.
//
// ## Decisions
//
// - Budget grows ten percent.
//   > "we grow the budget by ten percent" (01:05)
// ...
// **[01:05] Other:** Yes, we grow the budget by ten percent.
//   - Matches your notes: Budget up ten percent. (Note: Budget)

toPlainText(doc);
// ...
// [01:05] Other: Yes, we grow the budget by ten percent.
//   Matches your notes: Budget up ten percent. (Note: Budget)

JSON.parse(toJson(doc)).format;
// 'transcript-export'

toHtml(doc);
// '<h1>Budget review</h1><h2>Summary</h2><p>The team agreed the budget.</p><h2>Decisions</h2><ul><li>...'

actionItemsCsv(doc);
// U+FEFF, the byte order mark, then:
// "Action item","Owner","Quote","Time"
// "Send the slides.","Ann","I'll send the slides","01:10"

toSrt(doc);
// 1
// 00:00:00,000 --> 00:00:02,000
// You: Shall we start?
// ...

toVtt(doc);
// WEBVTT
//
// 00:00:00.000 --> 00:00:02.000
// You: Shall we start?
// ...

const docx: Uint8Array = toDocx(doc);

const zip = writeArchive([
  { path: 'sessions/budget-review.md', data: toMarkdown(doc) },
  { path: 'sessions/budget-review.docx', data: docx },
]);
readArchive(zip).map((entry) => entry.path);
// ['sessions/budget-review.md', 'sessions/budget-review.docx']

The CSV starts with a byte order mark (U+FEFF, the bytes EF BB BF once encoded as UTF-8): Excel opens a UTF-8 CSV file normally when it was saved with one, and without one it can misread text outside ASCII, a person's name included. A reader that does not strip the mark finds it in front of the first header.

The subtitles leave out a turn with no start and list the cues by their start, whatever the order of the turns: WebVTT requires each cue to start no earlier than the cues before it. Turns with the same start keep their order. A turn with no end ends at the next cue's start or two seconds after its own start, whichever comes first.

What each format is guarded against, with the helper the entry exports:

  • XML and HTML text (xmlText): characters outside XML 1.0's Char production are dropped, and &, <, >, " and ' are escaped.
  • A WebVTT cue (vttText): & and < are escaped, every --> is broken, and no blank line is left inside the cue. An SRT cue (cueText) keeps no blank line inside.
  • A CSV field (csvField): every field is quoted with its quotes doubled, and a field that starts with =, +, -, @, a tab, a carriage return, a line feed or the full-width =, +, - or @ gets a leading single quote, so a spreadsheet does not read it as a formula.
  • Markdown body text (markdownText): marks are escaped and GitHub's mentions and references broken, as Markdown body text describes.
  • An archive path: relative, with no empty part, no .. and no backslash. writeArchive refuses any other path, and readArchive refuses an archive with a file under one. A folder entry (a name ending in /, which zip tools write for a folder) is left out of what readArchive answers.
  • An archive's size (readArchive): each file's size is added to a running total before the file is inflated, and a total past maxInflatedBytes throws a RangeError, so a small archive that declares or holds gigabytes is never inflated in full. The default is 52,428,800 bytes (50 MiB); pass a larger bound for an archive known to hold more, as in readArchive(bytes, { maxInflatedBytes: 512 * 1024 * 1024 }). An archive whose files claim more compressed bytes than it holds, as files that share one stretch of data do, throws a RangeError too. The bound is on the bytes held, not on the time a read takes.

Writing an archive entry by entry

With options, writeArchive(entries, options) answers a promise and writes the zip one entry at a time, so a long export can show its progress and be stopped:

import { ArchiveError } from '@framers/agentos-ext-document-export/archive';
import { writeArchive } from '@framers/agentos-ext-document-export/transcript';

const controller = new AbortController(); // controller.abort() from a Stop key
try {
  const zip = await writeArchive(entries, {
    onEntry: (index, path) => console.log(`${index + 1} of ${entries.length}: ${path}`),
    signal: controller.signal,
  });
} catch (error) {
  if (!(error instanceof ArchiveError && error.reason === 'stopped')) throw error;
  // Stopped after the entry in hand; no archive was handed back.
}
OptionWhat it does
onEntry(index, path)Called once as each entry has been written, with its index in entries and its path, in order
signalRead before each entry and once after the last. Once it is aborted, the write stops after the entry in hand and the promise rejects with ArchiveError, reason stopped, handing back no archive
mtimeThe modification time written on every entry, milliseconds or a Date from 1980 to 2099, so the same entries give the same bytes. Default: the clock's, as writeArchive(entries) writes

Between two entries the write waits for a timer (setTimeout(fn, 0)), so a message sent to the thread, a worker's stop among them, is handled after the entry in hand. Every path is checked before the first entry is written, and a bad one, or one given twice, rejects the promise before onEntry is called; writeArchive(entries) keeps the last entry's data for a path given twice. With no options, writeArchive(entries) is the synchronous call above, unchanged. The two forms write different bytes for the same entries: the entry-by-entry form gives each entry's sizes in a data descriptor after its data (APPNOTE 4.3.9), as a streaming writer does, and readArchive and openArchive read both alike.

Archives (browser-safe)

@framers/agentos-ext-document-export/archive reads a ZIP too large to inflate whole, such as a folder of notes chosen in a phone's browser. openArchive lists the entries from the archive's central directory and inflates none of their data; each entry's bytes() reads that entry alone from its own slice of the Blob.

import { ArchiveError, openArchive } from '@framers/agentos-ext-document-export/archive';

const archive = await openArchive(file, { maxEntries: 10_000, signal });
try {
  for (const entry of archive.entries) {
    if (entry.kind !== 'file' || entry.unsafe) continue;
    const bytes = await entry.bytes({ maxBytes: 10 * 1024 * 1024, signal });
    // one entry's bytes, checked against its recorded length and CRC-32
  }
} catch (error) {
  if (error instanceof ArchiveError && error.reason === 'size') {
    // the entry's recorded size is more than maxBytes
  }
  throw error;
} finally {
  archive.close();
}
OptionWhereWhat it bounds
maxEntriesopenArchiveThe entries the archive may list, checked on the end record before the directory is read. Default: none
maxDirectoryBytesopenArchiveThe directory's size, checked before it is read. Default: 64 MiB (MAX_DIRECTORY_BYTES)
maxBytesbytes()The entry's bytes. A recorded size past it is refused as size before any read. Each 8 KiB step's output is then counted before it is kept, and the step that takes the output past the recorded size is refused as format, so the output never passes maxBytes. Default: none; the recorded size alone then bounds what a read holds
workerbytes()Inflate in fflate's worker (default) or, with false, on the calling thread. The worker is given the next step only after it has taken the one before. fflate makes its worker from a blob: URL, so a page whose Content Security Policy does not allow blob: workers reads with false
signalbothStops the open or the read, at once even while a read of the Blob is pending or a step is on its way to the worker

What an open reads

The open reads the archive's end and its directory, and inflates and returns no entry's data:

  • With no archive comment: the last 22 bytes (the end record), the 20 bytes before them, where a ZIP64 locator sits (not read when fewer than 20 bytes come before the end record, as in an empty archive), and the directory.
  • With a comment: the last 22 bytes, then the last 65,557 bytes at most (the longest comment and the end record), the 20 bytes before the end record and the directory. An archive shorter than 65,557 bytes is read whole at that second read, entries' data included.
  • A ZIP64 archive adds its ZIP64 end record's first 56 bytes.

The directory is read in one slice, after its size is checked against maxDirectoryBytes.

Names and kinds

Each listing has path, size, compressedSize, method (0 stored, 8 DEFLATE), encrypted, kind and unsafe.

  • path is the entry's name as text: UTF-8 when the entry's general-purpose bit 11 is set. Without it, the name of an Info-ZIP Unicode Path extra field (0x7075) whose CRC-32 matches the stored name; else the stored name as UTF-8 when its bytes are valid UTF-8, as many writers store it without the flag; else IBM code page 437, the ZIP format's original character set. path is display text, never a path to resolve.
  • kind is directory from a trailing slash or a folder attribute, link from Unix symlink bits, else file.
  • encrypted is true when the entry's directory record sets general-purpose bit 0 (encrypted) or bit 6 (strong encryption). Such an entry is refused as method when read. Traditional encryption, as Info-ZIP's zip -e writes it, keeps the method of the data under it, so its entries list method 8 with encrypted true: read both to leave out, before any read, every entry this reader refuses for how it is written.
  • unsafe marks a path with an empty part, ., .., a backslash, a null character or a drive prefix: listed, never read.

What an archive must hold

ZIP64 archives are read; archives on more than one disk are not. The ZIP64 end record is read when its locator sits right before the end record, and its own size must be at least 44 bytes and end where the locator begins or before. Without a locator, the end record's numbers are taken as written, so an archive of exactly 65,535 entries, which fflate and Python's zipfile record as 0xFFFF without ZIP64, opens.

The directory holds the records the end record counts, then nothing or one digital signature record; any other bytes in it refuse the archive. An entry's local header must repeat its directory record's compression method, encryption bits and name, byte for byte, or the entry is refused before any of its data is read.

Reads

One entry of an archive is read at a time. close() stops an active read, ends its worker and refuses every later read. However a read ends (its last step, a refusal, the signal, close() or a failed read of the Blob), its worker ends with it. A stop that lands after a step is read and before the worker is given it refuses the read there as stopped, and the ended worker is given nothing more. A read stopped by its signal lets the archive go, so the next read runs; after close(), every later read is refused as stopped.

On the calling thread a read stops at the step that ends the entry's DEFLATE stream: fflate's inflater shows the end after each step, and nothing after that step is read or inflated.

fflate's worker cannot show the end, so a worker read whose steps inflate to nothing 16 times in a row (128 KiB) feeds the worker no more of the entry, and the worker is given at most 16 steps and one byte past the step that ends a stream:

  • When the output is then the entry's whole recorded length, the worker is given the entry's next byte as its last step. A stream that has ended takes it without reading it, and the read is done with the stream inflated once, whatever follows it inside the entry's compressed size. A stream still going refuses the byte as cut short, unless that byte finishes the block the worker holds and the stream with it: one byte holds no DEFLATE block whole. An empty last step would not tell the two apart: an inflater that holds no input takes an empty last step as the stream's end, ended or not, and it holds none whenever the steps before it end exactly where a block ends on a byte boundary.
  • Otherwise, or when the worker refuses that byte because the stream is still going, the worker is ended and the entry is read again on the calling thread.

A valid stream is read whatever empty blocks it holds, and a stream that gives more than the recorded size is refused as format on either thread. If the installed fflate's inflater does not show its state as fflate 0.8.3's does, a stream whose steps inflate to nothing 16 times in a row is finished there on the calling thread too, with the entry's next byte as its last step, and one still going is refused as format.

Every refusal is an ArchiveError whose message is fixed and never carries an entry's name or the Blob's own error:

reasonWhen
formatNot a ZIP this reader opens: no end record, a malformed ZIP64 end record, numbers that do not fit the archive, bytes in the directory past its records other than one digital signature, entries whose data overlap, a missing local header or one that does not repeat its directory record, invalid DEFLATE, bytes that do not match the recorded length or CRC-32, or a Blob that could not hand back its bytes
methodA compression other than stored or DEFLATE, or an encrypted entry
sizeThe entry's recorded size is more than maxBytes
pathThe entry is unsafe
entriesMore entries than maxEntries
directory_sizeA directory larger than maxDirectoryBytes
busyAnother entry of the same archive is being read
stoppedThe signal fired, or the archive was closed, a read of the Blob still pending included; for writeArchive with options, its signal fired before an entry or after the last

A bound that is negative or not a number throws RangeError.

Calendar events (browser-safe)

@framers/agentos-ext-document-export/calendar writes one event as an iCalendar file and as the address of Google Calendar's new-event page. It imports no Node built-in and no package, so a page builds both offline. Neither writes to anyone's calendar: the person opens the file, or saves the event on Google's page.

import { googleCalendarAddress, icsEvent } from '@framers/agentos-ext-document-export/calendar';

const ics = icsEvent({
  uid: '[email protected]',
  title: 'Send the slides',
  description: 'From the budget review: "I\'ll send the slides"',
  start: '2026-11-12',
  allDay: true,
  stamp: new Date(),
});
const href = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
// a link with this href and download="send-the-slides.ics" saves the file

window.open(googleCalendarAddress({ title: 'Send the slides', start: '2026-11-12', allDay: true }), '_blank', 'noopener');
// https://calendar.google.com/calendar/render?action=TEMPLATE&text=Send%20the%20slides&dates=20261112/20261113
FieldicsEventgoogleCalendarAddressWhat it takes
uidUIDThe event's identifier. Keep it the same for every file made for one event, so a calendar that opens a second file updates the event instead of adding another
titleSUMMARYtextThe title
description, detailsDESCRIPTIONdetailsNotes, written when not empty, line breaks kept
startDTSTARTdatesFor a timed event, an instant: a Date, or an ISO 8601 date-time with its offset (2026-11-12T10:00:00-05:00), a fraction of a second of any length cut to the second when written (2026-11-12T15:00:00.123456+00:00). For an all-day event, its first day as YYYY-MM-DD
endDTENDdatesIn the form start takes. A timed event ends at it, and ends when it starts without it or when it falls in the second the event starts in. An all-day event covers it as its last day, and covers its first day alone without it
allDayWhole days rather than a time of day. Default false
stampDTSTAMPWhen the file was made, an instant
productIdPRODIDThe product that made the file. Default -//Framers//AgentOS Document Export//EN

The file follows RFC 5545:

  • Lines end in CRLF and fold at 75 octets, each continuation starting with a space (section 3.1). A fold falls between characters, never inside one's UTF-8 bytes, so every line is valid UTF-8 and unfolding gives back the text whole.
  • Text escapes \, ; and , with a backslash and writes each line break as \n (section 3.3.11). The control characters TEXT does not allow are dropped; a tab is kept.
  • A timed event's times are UTC date-times (DTSTART:20261112T150000Z, section 3.3.5). A timed event given no end, or an end in the second it starts in, has no DTEND, which RFC 5545 reads as ending when it starts (section 3.6.1), since a DTEND must be later than DTSTART (section 3.8.2.2).
  • An all-day event's times are dates (DTSTART;VALUE=DATE:20261112, section 3.3.4), and its DTEND is the day after its last day, since DTEND is not inclusive (section 3.6.1).

The template address writes action=TEMPLATE, text, dates and details, with text and details percent-encoded. dates is two UTC date-times (20261112T150000Z/20261112T153000Z), the start twice for a timed event given no end or an end in the second it starts in, or for an all-day event two dates, the second the day after its last day (20261112/20261113). Google publishes no reference for these parameters. They are written as the Add Event to Calendar docs record them, read on 10 November 2026; those docs also say Google renders basic HTML in details.

Each helper throws a RangeError naming the field when a time is not in the form the event takes, a time falls outside the years 0 to 9999 or end falls in a second before the one start falls in, and icsEvent throws one when uid or productId is empty or holds only white space and control characters, which a TEXT value drops. A lone surrogate in a text is written as U+FFFD.

Markdown body text (browser-safe)

markdownText(text), exported from the package and from ./transcript, makes plain text safe to place in a Markdown body, such as an issue's, where it reads as written:

import { markdownText } from '@framers/agentos-ext-document-export/transcript';

markdownText('# x [y](z) @a #12');
// '\\# x \\[y\\](z) @​a #​12'
// GitHub shows "# x [y](z) @a #12" with no heading, link, mention or reference
  • CommonMark marks are escaped with a backslash: \ ` * _ [ ] < & ~ | anywhere, and a line's first #, >, +, = or - and an ordered list marker (1., 12)). Nothing starts a heading, a list, a quote, a thematic break, a table, a link, an image, HTML, an entity, a code span or emphasis.
  • GitHub links mentions and references after it reads the Markdown, and neither a backslash nor an HTML entity stops it, as its Markdown API rendered on 10 November 2026. A zero-width space (U+200B), which shows nothing, goes after the @ of a mention or an address, the # of #12 and owner/repo#12, the GH- of GH-12, the issues/, pull/ or discussions/ before a number, the colon of https://, the www of www. and the opening colon of an emoji shortcode (:tada:), which GitHub draws as an emoji whatever comes before it. None of them then links or draws, and no one is notified.
  • Line breaks are written as LF, and each line's indentation is dropped, since four spaces start a code block, where escapes show.

Two things still render on GitHub. A bare commit hash links to its commit when the repository holds one. Text between two dollar signs can render as math, and a backslash does not stop it there: GitHub's math page asks for a <span> around such a dollar sign. A reader who copies the text copies the zero-width spaces with it.

版本与发布记录

@framers/agentos-ext-document-export 共发布 15 个版本,首次发布于 2026/05/02;过去 12 个月发布了 15 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.3.10latest2026/11/114 天前
0.3.92026/11/096 天前
0.3.82026/11/0510 天前
0.3.72026/11/0114 天前
0.3.62026/10/2819 天前
0.3.52026/10/2125 天前

关于 @framers/agentos-ext-document-export 的常见问题

@framers/agentos-ext-document-export 是什么?

Generate PDF, DOCX, PPTX, CSV, and XLSX documents with charts, tables, and themes

如何安装 @framers/agentos-ext-document-export?

在项目目录运行 npm install @framers/agentos-ext-document-export,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@framers/agentos(>=0.10.40)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@framers/agentos-ext-document-export 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@framers/agentos-ext-document-export 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@framers/agentos-ext-document-export 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@framers/agentos-ext-document-export 可以商用吗?

@framers/agentos-ext-document-export 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@framers/agentos-ext-document-export 的下载量有多少?

根据 npm 官方下载统计,@framers/agentos-ext-document-export 在 2026/10/16 – 2026/11/14 这 30 天内被下载 24,076 次,日均约 803 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@framers/agentos-ext-document-export 还在维护吗?

最新版本 0.3.10 发布于 2026/11/11(4 天前);至今共发布 15 个版本,其中过去 12 个月发布了 15 个;首次发布于 2026/05/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。