Skip to content

format: 43 inline run properties are writable and none of them are readable #3982

Description

@Nathaniel-260

First, thank you for this package. Building a full Word-compatible editor for a
right-to-left, typographically demanding domain on top of it has been possible in a way it
simply is not with anything else available, and the Document API is a large part of why: the
operation contract is unusually well modelled, the receipts say what actually happened, and
the property registry in format/inline-run-patch.ts is a genuinely good piece of design.
The responsiveness on issues has been excellent too — #3944 went from report to fix quickly,
and that is not something I take for granted.

This issue is about the one place where that design is currently only half present.

What problem does this solve?

A caller can write inline formatting through the Document API and can never read it back.

packages/document-api/src/format/inline-run-patch.ts defines InlineRunPatch with 43
properties — bold, fontSize, letterSpacing, charScale, position, kerning,
vanish, bCs, iCs, fontSizeCs, rFonts, lang, vertAlign, … — and
INLINE_PROPERTY_BY_KEY gives each one its storage kind (mark or runAttribute), its
OOXML element and its sd-prop alias. format.apply writes all 43.

Nothing reads one back. format.apply is the only format.* entry in
packages/document-api/src/contract/operation-definitions.ts. The nearest read is
selection.current, and SelectionInfo.activeMarks is a string[] of ProseMirror mark
type names with intersection semantics. That answers "is the selection bold" for the
mark-backed booleans and nothing else: no values — no fontSize, no letterSpacing, no
rFonts — and nothing at all for the 31 properties stored as run attributes.

The complex-script stack lives entirely in that unreadable half. bCs, iCs,
fontSizeCs, rFonts.cs, rtl, cs and lang.bidi are what a Hebrew or Arabic run
actually carries, and all seven are write-only.

Why this is needed, concretely

A formatting dialog cannot show state. We ship a Word-style advanced font dialog on top
of format.apply: character spacing, position, kerning, horizontal scale, the effect
toggles, hidden text, and the complex-script group. Every control in it opens on "no
change", because there is no way to ask what the selection currently is. Word's font dialog
opens showing the current state and the user edits it. Ours can only accept new values — a
user cannot find out whether the selected text is already bCs-bold, cannot see the current
character spacing before changing it, and cannot see the fields they are leaving untouched.
That is not a missing convenience; it is the difference between editing formatting and
overwriting it blind.

A toolbar cannot render active state. Anything beyond the six mark-backed booleans has
no active/inactive/mixed state to render. For a right-to-left document that is most of what
matters: the run is bold because of w:bCs, sized by w:szCs, and fonted by w:rFonts/@cs
— and none of those three can be reflected in a button or a field.

Round-tripping is impossible. A host that wants "copy formatting from here, apply it
there", or "show me what this style resolves to before I override it", or simply "restore
what the user had before this dialog", has no source for the first half of the operation.
format.apply is a patch API without a corresponding get.

It is not only a UI problem. An agent driving the document through the operation
contract is in the same position: it can set 43 properties and inspect none of them, so it
cannot make a decision conditioned on the formatting that is already there.

The repository already shows that this read is needed.
packages/superdoc/src/public/ui/create-super-doc-ui.ts reads formatting through
format.readEffectiveInlineUniformity, described there as "Cached async access to the
INTERNAL format.readEffectiveInlineUniformity read (duck-typed; absent on hosts that do
not provide it)". Its key set is a fixed six — fontFamily, fontSize, bold, italic,
underline, strikethrough — and the comment beside it notes that every caller must
request that exact superset, because the shared cache entry is keyed by selection signature
rather than by the requested keys. So the mechanism exists and SuperDoc's own toolbar
depends on it; what is missing is a public, documented form of it that is as wide as the
write surface it mirrors.

Proposed solution

A public read that mirrors format.apply, driven by the registry that already exists.

const state = await api.format.read({
  target,                                                   // the SelectionTarget format.apply takes
  keys: ['bCs', 'fontSizeCs', 'letterSpacing', 'rFonts'],   // optional; default: all
});

state.values.bCs;            // { state: 'uniform', value: true }
state.values.letterSpacing;  // { state: 'mixed' }
state.values.rFonts;         // { state: 'uniform', value: { cs: 'David' } }

Shape notes, each with the alternative I rejected:

  • A three-state verdict per key — uniform with a value, mixed, unresolvable — not a
    flat value.
    A range selection genuinely has no single answer for a key, and a toolbar
    has to render that difference (Word greys the field rather than showing the first run's
    value). activeMarks collapses this by intersecting, which is exactly why it cannot
    carry values.
  • Keyed by InlineRunPatchKey, with the value shapes of InlineRunPatch. A value read
    back is then a value that can be written straight back through format.apply.
    Round-tripping is the point: a dialog reads the selection, the user edits two fields, and
    the whole patch goes back.
  • keys optional. A caller who wants everything should not have to enumerate 43 names,
    and a toolbar polling on every selection change should be able to ask for six.
  • Effective (cascade-resolved) values by default, matching what the internal read
    already gives the toolbar — with a resolution: 'effective' | 'direct' selector if you
    want direct run properties reachable too. A dialog that writes direct run properties
    needs to know which of the values it shows are inherited.
  • format.read, which reads naturally against format.apply. If the existing internal
    operation is meant to stay as it is, format.readInline works equally well — the name is
    yours to pick.

Alternatives considered

  • selection.current().activeMarks. Names only, no values, blind to every
    runAttribute property. Enough for a bold toggle, not for a font dialog.
  • Duck-typing format.readEffectiveInlineUniformity from the host, the way
    create-super-doc-ui.ts does. It is marked internal and documented as possibly absent,
    the shared cache constrains which keys a caller may ask for, and its six keys exclude
    every complex-script property — so it cannot answer the question this issue is about,
    even for a host willing to depend on an internal.
  • Reading the OOXML ourselves. We hold the bytes and already pre-process them before
    handing them to the engine. Mapping a live selection back onto runs in document.xml
    duplicates the engine's own address resolution and goes stale on the first edit.
  • Leaving every control blank, which is what we ship today. Correct, and a permanent
    downgrade from the dialog it mirrors.

Additional context

I would like to write this one. I am offering to open the contract-side PR — operation
definition, schemas, validation, types, all three docapi:check stages, unit tests and a
tests/consumer-typecheck fixture asserting both the parameter and return shapes — in the
same shape as #3975, and to keep iterating on it through review. Say the word and I will
start; I would rather agree on the approach first, as CONTRIBUTING asks.

The one thing I cannot write is the read itself, which lives in @superdoc/docx-engine
outside this repository. That is the same contract-without-adapter situation as #3975, and
the question I asked at the bottom of that issue — who owns the engine side, and whether a
contract may land ahead of it — decides this issue too. I am happy with whichever answer you
give there, including "we will take this one ourselves"; the shape above is yours to use or
discard either way.

Related: #3959 is the display half of the same gap (w:bCs is parsed and never rendered).
This issue is the read half.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: queuedEngineering work is queued; no delivery date is committed.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions