Skip to Content
PackagesRedaction

Redaction

Mark what must not be disclosed, review it, and export a version with it removed: clinical study reports for EMA Policy 0070 or Health Canada PRCI, disclosures under FOIA or a GDPR subject access request, litigation productions.

The document is not the redacted artefact; the export is. The editor keeps the original text under every redaction, and so do collaboration history, version history and the stored document. Only a final export removes it, from every byte of the file, and a leak check reads the file before it is given out.

Packages

PackageContents
@stesura/redactionredactionPlugin, commands, collectRedactions, the export pass (prepareRedactedExport), DOCX and PDF wiring, the leak check, reports, REDACTION_PRESETS
@stesura/redaction/pdfcheckPdfLeaks, extractPdfSurfaces (Node only: pdfjs reads the text layer)
@stesura/redaction-reactcreateRedactionUIExtension: Review-tab group, context menu, right panel with suggestions, picture-area editor, export dialog, reason tints

The redaction mark and attribute are in the core schema, so a document keeps its redactions in any editor built on it.

Setup

import { redactionPlugin, redactionSuggestionsPlugin, REDACTION_PRESETS } from "@stesura/redaction"; import { createRedactionUIExtension } from "@stesura/redaction-react"; // in the mount hook's extraPlugins redactionPlugin(); redactionSuggestionsPlugin(); // optional: the panel's Suggestions tab // in uiExtensions (module level) const redactionUi = createRedactionUIExtension({ taxonomy: REDACTION_PRESETS.ema, // Optional: the panel's Export button. Runs the pass, the render and the leak check. exportRedacted: async (request) => myExport(request), });

REDACTION_PRESETS holds the EMA, Health Canada, US and UK FOIA, GDPR and litigation reasons; a taxonomy is a RedactionTaxonomy, a list of { code, label, description, color }. A code not in the taxonomy is kept and flagged, never dropped.

Marking

A redaction is the redaction mark (RedactionAttrs: id, reason, status, author, date, note) on text and inline atoms (pictures in line, formulas, references, fields), and the redaction attribute on block leaves (a picture, an equation, a diagram, an embedded file). One redaction split across paragraphs keeps one id. Adding, removing or confirming one is not a tracked change: it has its own review, proposed then confirmed.

CommandDoes
addRedaction(input)Redacts the selection; a node selection redacts the block whole
redactAllOccurrences(term, input)Redacts every occurrence, each its own redaction, matched literally
setRedactionStatus(ids, status)Accepts (confirmed) or reopens (proposed)
setRedactionReason(ids, reason)Changes the reason
removeRedaction(id), removeRedactions(ids)Un-redacts
addRedactionRegion(pos, input)Redacts an area of the picture at pos (RedactionRegionInput)
setRedactionRegions(pos, regions)Replaces the picture’s areas (the area editor’s save)

collectRedactions lists them by id (CollectedRedaction); a block whose content is all redacted under one reason counts as fully redacted (isFullyRedacted in core), derived, never stored. Pasting re-mints ids already in the document (remintRedactionIds, redactionIdsIn). The plugin also draws the panel’s hover outline (setRedactionFocus) and the “preview final” (setRedactionPreview, RedactionPreview: proportional boxes as CSS, fixed labels as decorations), read back with getRedactionPluginState (RedactionPluginState, redactionPluginKey).

Picture areas

A picture (image, image_anchored) can have redacted areas instead of being redacted whole: the redactionRegions attribute (RedactionRegion: id, x, y, w, h as fractions of the uncropped bitmap, reason, status, author, date; read with redactionRegionsOf and toRedactionRegions from core). The editor draws them over the picture, following its crop and turn. They are listed, accepted and removed like any redaction (CollectedRedaction.kind is region). In the area editor, a drag draws an area; from the keyboard, Tab to the picture, the arrow keys move a new area, Shift with them resizes it (Ctrl for bigger steps) and Enter adds it.

A DOCX save and a proposed DOCX export keep them, in the same custom XML part as the redactions’ details, keyed by the picture’s bytes (Word keeps them when it saves; it renumbers everything else). Copies of one picture with different areas each come back with all of them, with a warning: too much redacted, never too little. A picture replaced or recompressed outside the editor loses its areas on import, with a warning.

A final export burns them into the pixels first: burnRedactionRegions(doc, options) (BurnRedactionRegionsOptions, BurnRedactionRegionsResult) redraws each picture without its cropped-out parts and with its areas filled black, re-encoded (no EXIF), and flags the areas burned (the pass lists them in its report, then writes none to the file), through a RasterizeRegions function on ImageBytes: rasterizeRegionsInBrowser in a browser, the host’s own in Node. A picture it can’t read fails the burn; the pass refuses a document that still has areas (countUnburnedRegions, refusal unburned-regions).

const burned = await burnRedactionRegions(doc, { schema, rasterize: rasterizeRegionsInBrowser }); if (burned.ok) prepareRedactedExport(burned.doc, { schema, mode: "final" });

Suggestions

redactionSuggestionsPlugin({ detectors }) (RedactionSuggestionsOptions) finds strings worth redacting and draws them as decorations; they are never marks and never enter the document. Accepting one creates a redaction (acceptRedactionSuggestions, acceptRedactionSuggestionsTr); dismissing one hides every occurrence of the string for every collaborator, stored in doc.attrs.dismissedFindings under SUGGESTIONS_CHECKER as a hash (suggestionKey), never the string (dismissRedactionSuggestions, restoreRedactionSuggestions).

DetectorFinds
emailDetectoremail addresses
phoneDetectorphone numbers: international, bracketed area code, trunk zero
dateDetectorISO, numeric and named-month dates in six languages
ibanDetectorIBANs with valid check digits
patternDetector(pattern)a host’s identifier (IdPattern: subject or site numbers); host configuration, never user input
termsDetector(terms)every use of a list of terms, e.g. contract-check’s buildTermIndex(doc).terms

DEFAULT_DETECTORS holds the first four, in priority order (an earlier one wins an overlap). A RedactionDetector runs on one block’s text at a time and returns DetectedSpans; createSuggestionEngine (SuggestionEngine) scans only blocks it has not seen. The suggestions (RedactionSuggestion) are read with getRedactionSuggestions; the panel turns the scan on while its tab is open (setRedactionSuggestionsActive) and points at one on hover (setRedactionSuggestionFocus). RedactionSuggestionsState and redactionSuggestionsPluginKey hold the plugin state. createRedactionSuggestionIssues(input) is an IssueProvider for @stesura/ai’s createIssueTools: the agent’s list_issues sees them, and accepting one is a direct fix.

The root editor scans; a sub-editor (a text box, header or note being edited) never does: its own instance of the plugin draws the root’s suggestions that fall in its region, found by block id, with the root’s hover focus. Content shown statically (a text box or header not being edited) is not underlined.

AI suggestions (opt-in)

Detection by a model sends the document’s text to it: wire it only to a model you control (self-hosted, or under a data processing agreement). The prompt is server-side and not a built-in action: register redactionEntitiesTemplate from @stesura/ai under REDACTION_ENTITIES_ACTION on a text-action route, then give the UI a transport.

// route createTextActionHandler({ model, actions: { [REDACTION_ENTITIES_ACTION]: redactionEntitiesTemplate } }); // editor createRedactionUIExtension({ taxonomy, aiDetect: { generate: createHttpGenerateText("/api/ai/text-action") } });

detectWithAi(doc, options) (DetectWithAiOptions, RedactionAiGenerate) is requestAiEntities (the model’s AiEntity list, parsed by parseEntities) then locateAiEntities, which finds each string in the document as it is when the answer arrives: a string the document doesn’t hold suggests nothing. The results reach the plugin with setExternalRedactionSuggestions (externalSuggestion builds one by hand) and follow edits until their text changes.

Exports

Two versions, DOCX and PDF, through one pure pass in front of the unchanged exporters: prepareRedactedExport(doc, options) (PrepareRedactedExportOptions, PrepareRedactedExportResult).

ModeContent
proposedRedactions marked, text readable: for the agency or a reviewer. In DOCX each range is a content control tagged with its id and titled with its reason, so the file reopens still redacted.
finalEach redaction’s text is replaced by its reason code, [PPD], under a final mark. Abutting ranges of one reason are one box.

Both modes export tracked changes as accepted (a resolver must be wired, e.g. resolvedView from @stesura/track-changes; countTrackedChanges counts them first) and drop every comment. A final export refuses (RedactedExportRefusal) while redactions are still proposed, unless told to redact them too. RedactedExportMode, RedactedExportReport and RedactedExportWarning describe the rest.

Final boxes are a black fill with a white code or, with boxStyle: "outline" (RedactionBoxStyle), a white box with a black border. Their size (RedactionBoxMode) is proportional (the default for PDF: each box the size of the text it replaces, so the layout does not move and the length shows) or fixed (each box fits its label; a fully redacted block becomes one box three lines high, never split across pages). A proportional PDF is rendered twice: the proposed document is laid out first and each range measured in the export browser, then the final one is printed with those sizes (exportRedactedPdf, ExportRedactedPdfOptions, ExportRedactedPdfResult, RedactedPdfRenderer, RedactionMeasurements, readRedactionMeasurements, MergedRedaction and mergeRedactions for the keys; the export page measures with measureRangeFragments from @stesura/pagination). DOCX takes redactedDocxOptions (RedactedDocxOptions); a refusal throws RedactedExportRefusedError.

Anonymisation

With anonymise (AnonymiseOptions), a final export writes readable replacements instead of labels, as plain text, one per redaction (two abutting names are two pseudonyms): each distinct redacted string a consistent pseudonym (“Person A”, or the host’s pseudonym(index, reason)), a date shifted by dateShiftDays in its own format (shiftDate), an age with its unit in a band (ageBand: “43 years” becomes “40–49 years”). A bare number, and a range holding a picture or another object, keep their label. createAnonymiser (Anonymiser) is the replacement on its own.

Per subject. With subjects (SubjectDateShift), each subject’s dates move by its own offset, so the intervals within a subject survive and no date lines two subjects up. A subject is a redacted string the host’s pattern matches in full (subjectMatcher), such as a subject number; it is host configuration, never user input (a server must not compile a pattern from a request). A redacted date belongs to the subject named in its table row, else to the last subject before it in its section (from one heading to the next); one with no subject takes dateShiftDays (assignSubjects, AnonymisedRun). Offsets come from shifts (a study’s key table, so its documents shift alike), else are random, 1 to maxShiftDays days either way. The report’s subjectShifts lists the offset each subject got, for the key table; it is never written to the file. createRedactionUIExtension({ subjects }) offers it in the export dialog.

prepareRedactedExport(doc, { schema, mode: "final", anonymise: { subjects: { pattern: String.raw`\d{4}-\d{3}`, maxShiftDays: 30 }, dateShiftDays: 7 }, });

What a final export removes

The redacted text, picture areas (burned into the pixels), and what repeats the text elsewhere in the file: cached reference text, citations and the bibliography’s sources, field results, alt text, a watermark, imported bookmark names, picture effects, links whose address runs through a redaction, callout titles, document properties (only the title and author you pass are written), and the editor’s own records (variables, dismissed findings). Comments and tracked changes never reach it. In a PDF, objects nothing references are deleted before saving.

What it does not

A chart or diagram is exported as it is: if it repeats a redacted string, the leak check blocks the download and points at it, to be redacted whole. An embedded PDF file is spliced as it is. A proportional box shows the length of what it hides. The stored document, collaboration history and version history keep the original text, by design.

Leak check

checkDocxLeaks(bytes, original) reads every part of a DOCX (text, attribute values, relationship targets, part names, embedded workbooks, the strings in pictures); checkPdfLeaks from @stesura/redaction/pdf reads a PDF’s text layer, outline, links, document information and every object’s strings. Both look for each redacted string (collectRedactedStrings, RedactedStrings, normalizeRedactedString) through findLeaks (FindLeaksOptions, LeakSurface, extractDocxSurfaces, extractPdfSurfaces) and return RedactionLeakFindings (RedactionLeakRule):

RuleFindsSeverity
RED-L1the string, exactlyerror
RED-L2the string with other case or accentserror
RED-L3text a few edits awaywarning
RED-L4one word of a short redacted string (a name)warning

leaksBlockDownload is true on any error. A string shorter than six characters matches only as a whole word. createStringScrubber is the pass’s own replacement of redacted strings in cached text.

Reports

renderRedactionReport(redactions, options) (RedactionReportOptions) writes the justification table (page, reason, label, justification, status, author, date) and an anonymisation section per reason, as Markdown, CSV or JSON. The redacted text is never in it.

Interface

createRedactionUIExtension (RedactionUIExtensionOptions) adds RedactionToolbarGroup (Redact with its reasons, the panel toggle) to the Review tab, RedactionContextMenu (Redact over a selection and, on a selected picture, its area editor) to the context menu, RedactionPanel (grouped by reason, accept and reject per item and per group, next and previous, redact all occurrences, preview, reports, export and, with the suggestions plugin, a RedactionSuggestionsTab) under REDACTION_PANEL_ACTION, RedactionRegionEditor (the picture-area dialog, opened with openRegionEditor) and RedactionReasonStyles (each reason’s tint). The panel’s export opens RedactionExportDialog, which calls the host’s RedactedExportHandler with a RedactedExportRequest (mode, box, box style, anonymisation) and shows its RedactedExportOutcome. useRedactions, useRedactionSuggestions, useRedactionActions (its detectAi resolves to an AiDetectionOutcome) and useRedactionPreview read and drive the root editor. supportsRedaction tells whether a schema has the mark; RedactionInput and RedactAllOptions are the commands’ inputs.

Last updated on