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
| Package | Contents |
|---|---|
@stesura/redaction | redactionPlugin, commands, collectRedactions, the export pass (prepareRedactedExport), DOCX and PDF wiring, the leak check, reports, REDACTION_PRESETS |
@stesura/redaction/pdf | checkPdfLeaks, extractPdfSurfaces (Node only: pdfjs reads the text layer) |
@stesura/redaction-react | createRedactionUIExtension: 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.
| Command | Does |
|---|---|
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).
| Detector | Finds |
|---|---|
emailDetector | email addresses |
phoneDetector | phone numbers: international, bracketed area code, trunk zero |
dateDetector | ISO, numeric and named-month dates in six languages |
ibanDetector | IBANs 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).
| Mode | Content |
|---|---|
proposed | Redactions 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. |
final | Each 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):
| Rule | Finds | Severity |
|---|---|---|
RED-L1 | the string, exactly | error |
RED-L2 | the string with other case or accents | error |
RED-L3 | text a few edits away | warning |
RED-L4 | one 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.