Bridge Training

PBN

Parse and diagnose PBN and DUP files, convert between PBNDeal and Deal, serialize one or many deals, and pre-process BridgeComposer comments.

Import from @workspace/bridge/pbn. The sub-path exports one object, pbn, two comment pre-processors, pbnCreatorHeader, the diagnostic registry and its types, the options of pbn.composeFile, the two types used by pbn.serializeMulti and DealCommentSummary, the result of engine.pbn.detectDealComments.

ExportDescription
pbn.parseFileParse a PBN or DUP File into raw PBNDeal[] and domain Deal[].
pbn.diagnose, pbn.diagnoseFileParse PBN text or a PBN or DUP File and report, line by line, what the file gets wrong.
pbn.composeFileWrite a PBN file from deals copied as written: split a file or merge several.
pbn.toDealConvert one PBNDeal to a domain Deal.
pbn.fromDealConvert a domain Deal back to a PBNDeal.
pbn.serializeSerialize one PBNDeal to PBN text.
pbn.serializeMultiSerialize several deals into one file.
pbnCreatorHeader% Creator and % Created directive lines, for the header option.
pbn.serializeHandPBN Deal tag value from HandsData, from North.
preprocessPbnCommentNormalize BridgeComposer comment escapes to HTML.
MultiDealEntry, PBNMultiSerializeOptionsTypes for pbn.serializeMulti.
PBN_DIAGNOSTIC_CODE, PBN_DIAGNOSTIC_SEVERITYDiagnostic codes and the severity of each.
PBNDiagnosis, PBNDiagnosedDeal, PBNDiagnostic, PBNDiagnosticCode, PBNDiagnosticParams, PBNDiagnosticSeverityTypes for pbn.diagnose.
ComposePBNOptionsOptions of pbn.composeFile.

PBN types (PBNDeal, PBNAuction, PBNPlay, PBNNotes, PBNNote, PBNCard, PBN…) live in @workspace/bridge/types. Error classes live in @workspace/bridge/errors.

Parsing files

pbn.parseFile(file: File): Promise<ParsePBNFileResult> never throws; every failure is returned in the result.

type ParsePBNFileResult =
  | { status: "success"; data: { rawDeals: PBNDeal[]; deals: Deal[]; directives: string[]; encoding: string } }
  | { status: "error"; error: PNBAggregateError | MaxFileSizeError | PBNTooManyDealsError };

The result types themselves are not exported. rawDeals keeps the file as parsed (tag values as strings, notes, custom tags including those with an empty value, and the data rows of table tags in tableRows); deals is the same list run through pbn.toDeal, so comments and review are strings at runtime even though the declared type is Deal[]. directives holds the % lines without their marker, except the PBN, EXPORT and key: value header lines, which are consumed by the parser; a charset= header only drives encoding detection.

pbn.ts
const source = `% PBN 2.1
[Board "1"]
[Dealer "N"]
[Vulnerable "None"]
[Deal "N:AKQ2.KQ3.A54.K32 JT98.JT9.KQJ.QJ9 7654.A87.T98.AT8 3.6542.7632.7654"]
[Contract "4S"]
[Declarer "N"]
[Result "10"]
[Auction "N"]
1NT =1= Pass 2C =2= Pass
2S  Pass 4S  Pass
Pass Pass
[Note "1:15-17 HCP balanced"]
[Note "2:Stayman convention"]
`;

const file = new File([source], "lesson.pbn");
const result = await pbn.parseFile(file);

if (result.status === "success") {
  const { deals, rawDeals, directives, encoding } = result.data;
  deals.length; // => 1 — domain `Deal[]`, comments and review are strings at runtime
  rawDeals[0]?.contract; // => "4S"
  rawDeals[0]?.notes?.auction[0]?.text; // => "15-17 HCP balanced"
  directives; // => [] (the `% PBN 2.1` header is consumed, not reported)
  encoding; // => "UTF-8"
}

Supported formats

  • PBN (Portable Bridge Notation): tag pairs, [Auction] and [Play] sections, [Note] tags, the data rows of table tags (OptimumResultTable, ScoreTable…), ; line comments and { } block comments. A missing % PBN header is added before tokenizing, and a comment following a tag on the same line ([Tag "v"] ; text) is promoted to a block comment. A comment inside an [Auction] or [Play] section, on its own line or between two tokens, joins review and the section goes on after it. In sections, Dbl and Rdbl are read as X and XX, and suffix annotations (!, ?, !?…) after a call or a card are dropped. A line the tokenizer cannot read, such as a tag without its closing bracket, is skipped; pbn.diagnose reports it.
  • DUP (Duplimate): detected by the .dup extension or by 78 leading ASCII digits. Each 156-byte record (BRI card codes for N, E, S; West inferred) becomes a [Board], [Dealer] (from the board number) and [Deal] tag. DUP carries no vulnerability, auction or play; the reported encoding is "ASCII".

Limits

LimitValue
Maximum file size10 MB, checked before reading (MaxFileSizeError)
Maximum deals per file1000, checked after parsing (PBNTooManyDealsError)

Encodings

SUPPORTED_ENCODINGS in @workspace/bridge/errors lists UTF-8, ISO-8859-1, windows-1252, UTF-16LE and UTF-16BE. Detection order:

  1. Byte-order mark: UTF-8, UTF-16LE or UTF-16BE.
  2. Strict UTF-8 decode; on failure, ISO-8859-1.
  3. A % … charset=<label> directive in the decoded text re-decodes the bytes with that label when it differs and produces no replacement character. Labels utf8, latin1, iso8859-1, cp1252 and utf-16 are normalized to the canonical names.

The encoding finally used is returned in data.encoding.

Errors

ClassThrown by parseFileFields
MaxFileSizeErrorfile larger than 10 MBmaxSize, actualSize (MB), type: "MAX_FILE_SIZE_EXCEEDED"
PBNTooManyDealsErrormore than 1000 dealsmaxDeals, dealCount
PNBAggregateErrorany other failure while tokenizing or convertingdetails: PBNErrorDetails — { type: "parse-error", message }, { type: "too-many-deals", max, actual } or { type: "encoding-mismatch", expected, detected }
PBNParseError, PBNEncodingMismatchErrorexported, never returned directly (PBNTooManyDealsError extends PBNParseError)expectedEncoding, detectedEncoding on the mismatch class

toPBNError(error) flattens any of them to { code: PBNErrorCode, message, details? } for callbacks. See Errors.

pbn.ts
if (result.status === "error") {
  if (result.error instanceof MaxFileSizeError) {
    result.error.maxSize; // MB
  } else if (result.error instanceof PBNTooManyDealsError) {
    result.error.dealCount;
  } else if (result.error instanceof PNBAggregateError) {
    result.error.details.type; // "encoding-mismatch" | "too-many-deals" | "parse-error"
  }
  toPBNError(result.error).code; // "MAX_FILE_SIZE_EXCEEDED" | "TOO_MANY_DEALS" | "PARSE_ERROR" | "ENCODING_MISMATCH"
}

SUPPORTED_ENCODINGS; // => ["UTF-8", "ISO-8859-1", "windows-1252", "UTF-16LE", "UTF-16BE"]

Diagnosing files

pbn.parseFile is lenient: it skips what it cannot read and keeps going. pbn.diagnose(content: string): PBNDiagnosis and pbn.diagnoseFile(file: File): Promise<PBNDiagnosis> run the same parser and report what it skipped or read differently from what the file says, with the source line of each finding. Neither throws. diagnoseFile reads the file as parseFile does (size limit, DUP conversion, encoding detection); diagnose takes text that is already decoded.

Each diagnosed deal carries the deal as parseFile returns it, in raw and deal, so a viewer needs only one call.

Prop

Type

Prop

Type

A PBNDiagnostic is { code, severity, line, params }. It is a union discriminated by code, so narrowing on the code types its params. line is the 1-based line of the source text, kept through comment pre-processing and CRLF line endings; it is undefined when the finding has no line, such as a file above the size limit.

SeverityMeaning
errorThe deal or the file cannot be shown as written.
warningShown, but a value was dropped or contradicts another.
infoAllowed by the format, worth knowing.
pbn.ts
const diagnosis = pbn.diagnose(`% PBN 2.1
[Board "1"]
[Dealer "Z"]
[Vulnerable "None"]
[Deal "N:AKQ2.KQ3.A54.K32 JT98.JT9.KQJ.QJ9 7654.A87.T98.AT8 3.6542.7632.7654"]
[Auction "N"]
1NT 1S Pass Pass
[Contract "3NT"
`);

diagnosis.diagnostics; // => [] — nothing about the file as a whole
diagnosis.encoding; // => undefined for text input
const [board] = diagnosis.deals;
board?.raw.name; // => "1"
board?.deal.auction.moves.length; // => 4 — the deal as pbn.parseFile reads it
board?.diagnostics;
// => [
//   { code: "DEALER_INVALID", severity: "warning", line: 3, params: { value: "Z" } },
//   { code: "AUCTION_CALL_ILLEGAL", severity: "warning", line: 6, params: { call: "1S", index: 2 } },
//   { code: "LINE_IGNORED", severity: "warning", line: 8, params: { text: '[Contract "3NT"' } },
// ]

const describeDiagnostic = (diagnostic: PBNDiagnostic): string => {
  switch (diagnostic.code) {
    case "AUCTION_CALL_ILLEGAL":
      return `Call ${diagnostic.params.index} (${diagnostic.params.call}) is not legal`;
    default:
      return diagnostic.code;
  }
};

const blocking = board?.diagnostics.filter(({ severity }) => severity === "error") ?? [];
PBN_DIAGNOSTIC_SEVERITY.DUPLICATE_CARD; // => "error"

const fromFile = await pbn.diagnoseFile(new PBNSourceBuilder().withDeals(2).toFile());
fromFile.encoding; // => "UTF-8"
fromFile.deals.length; // => 2

Codes

File-level findings are in diagnosis.diagnostics; all others are on their deal. Tricks and calls count from 1.

CodeSeverityParamsReported when
FILE_TOO_LARGEerrormaxMB, actualMBThe file is above 10 MB; it is not read.
FILE_UNREADABLEerrorA DUP file is corrupt, or the parser fails.
TOO_MANY_DEALSerrormax, countMore than 1000 deals; no deal is returned.
NO_DEALSerrorNo deal in the file.
CHARSET_IGNOREDwarningdeclared, usedA % … charset= directive names an encoding that cannot decode the file.
LINE_IGNOREDwarningtextThe tokenizer could not read the line, such as a tag without its closing bracket.
TAG_REPEATEDwarningtagA tag appears twice in one deal, the last one wins; usually a missing blank line between two deals. [Note] may repeat.
DEAL_MISSINGerrorNo [Deal] value, or a block between blank lines with no tag at all.
DEAL_FORMATerrorvalue[Deal] does not start with N:, E:, S: or W:.
DEAL_HAND_COUNTerrorcount[Deal] does not hold four hands.
DEAL_HAND_FORMATerrorseatA hand does not have four suits separated by dots.
DEAL_INVALID_RANKerrorseat, suit, rankA character is not a rank. 10 is two invalid characters; PBN writes T.
CARD_NOT_IN_DECKerrorseat, cardA card is outside the deck of the deal's [Teaching] mode, such as a 4 in petit-bridge.
DUPLICATE_CARDerrorcard, seatsA card is held twice.
HAND_SIZEerrorseat, count, expectedA hand does not hold the mode's number of cards: 13, or 10 in petit-bridge.
HAND_UNKNOWNinfoseatA hand is -, unknown; its cards are not counted.
BOARD_MISSINGinfoNo [Board].
BOARD_DUPLICATEwarningboardAnother deal before this one has the same board number.
DEALER_MISSING, VULNERABLE_MISSINGwarningNo [Dealer], or no [Vulnerable]. A DUP file carries no vulnerability and never gets VULNERABLE_MISSING.
DEALER_INVALID, VULNERABLE_INVALID, DECLARER_INVALID, CONTRACT_INVALID, RESULT_INVALIDwarningvalueThe parser cannot read the tag value and drops it.
AUCTION_DEALER_MISMATCHwarningdealer, first[Auction] does not start with the dealer.
AUCTION_TOKEN_UNKNOWNwarningtokenA token of [Auction] is neither a call nor a note, AP, $n, *, + or -; the parser drops it.
AUCTION_CALL_ILLEGALwarningcall, indexThe first call that was not legal when made: insufficient bid, double of partner, call after the auction ended.
CONTRACT_AUCTION_MISMATCHwarningcontract, auctionContractThe auction is complete and [Contract] is not its outcome (Pass when passed out).
DECLARER_AUCTION_MISMATCHwarningdeclarer, auctionDeclarerThe auction is complete and [Declarer] is not its declarer.
PLAY_TOKEN_UNKNOWNwarningtokenA token of [Play] is neither a card, a placeholder, a note nor $n; the parser drops it and the next cards shift.
PLAY_CARD_NOT_HELDwarningtrick, seat, cardThe first card played by a seat that does not hold it, or no longer does.
PLAY_SUIT_NOT_FOLLOWEDwarningtrick, seat, cardThe first card that does not follow suit while the seat could.

An empty tag value means "not specified" in PBN and is never invalid. The play is replayed only when all four hands are known and legal and the contract is known, and only the first illegal call and the first illegal card are reported, since what follows depends on them.

Splitting and merging files

A diagnosis also keeps the text of the file: header holds the lines before the first deal, and each deal's source holds its lines exactly as written, from its first to its last line. pbn.composeFile(deals: readonly string[], options?: ComposePBNOptions): string writes them back as a file. Splitting and merging never go through the serializer: it writes what a PBNDeal holds, normalized (Dbl as X, suffix annotations dropped, every comment in one block before the auction), and drops what the parser could not read, while a copy keeps the deal exactly as the user wrote it.

The result is meant to be saved as UTF-8: the header's charset directives are replaced by % Content-type: text/x-pbn; charset=UTF-8, and % PBN 2.1 is added when the header has no version line. With renumber, every [Board] of a deal takes its position in the file, from 1, and a deal without one gets it.

Prop

Type

pbn.ts
const club = pbn.diagnose(new PBNSourceBuilder().withDeals(3).build());

// Split: one file per deal, with the file's header
const perDeal: string[] = club.deals.map((deal) =>
  pbn.composeFile([deal.source], { header: club.header })
);

// Merge: the deals of several files, boards numbered 1 to n
const session = pbn.diagnose(new PBNSourceBuilder().withDeals(2).build());
const mergedFile = pbn.composeFile(
  [...club.deals, ...session.deals].map((deal) => deal.source),
  { renumber: true }
);
pbn.diagnose(mergedFile).deals.map((deal) => deal.raw.name); // => ["1", "2", "3", "4", "5"]

Converting deals

MemberSignatureDescription
pbn.toDeal(pbnDeal: PBNDeal) => Deal<string, string, string>Comments and review stay strings.
<A, P, R>(pbnDeal: PBNDeal, converters: { auctionCommentFromString; playCommentFromString; reviewFromString }) => Deal<A, P, R>Converts each comment with the given functions (for rich text).
pbn.fromDeal(deal: Deal<string, string, string>) => PBNDealInverse of toDeal.
<A, P, R>(deal: Deal<A, P, R>, converters: { auctionCommentToString; playCommentToString; reviewToString }) => PBNDealSerializes typed comments with the given functions.

toDeal rebuilds the chronological auction and play from the per-seat PBN columns, derives the contract from the [Contract] and [Declarer] tags (or from the auction), and parses vulnerability, scoring and the [Teaching] tag into metadata. fromDeal does the reverse: notes are numbered from 1 across auction then play comments, result is resolved from the metadata and contract, name is "" and customTags is empty.

pbn.ts
const pbnDeal: PBNDeal = new PBNDealBuilder().withOverrides({ contract: "3NT", declarer: "S" }).build();

const deal: Deal<string, string, string> = pbn.toDeal(pbnDeal);
deal.contract?.level; // => 3

const roundTrip: PBNDeal = pbn.fromDeal(deal);
roundTrip.contract; // => "3NT"
pbn.ts
type RichComment = { html: string };

const richDeal = pbn.toDeal<RichComment, RichComment, RichComment>(pbnDeal, {
  auctionCommentFromString: (text) => ({ html: preprocessPbnComment(text) }),
  playCommentFromString: (text) => ({ html: preprocessPbnComment(text) }),
  reviewFromString: (text) => ({ html: preprocessPbnComment(text) }),
});

pbn.fromDeal(richDeal, {
  auctionCommentToString: (comment) => comment.html,
  playCommentToString: (comment) => comment.html,
  reviewToString: (review) => review.html,
});

Serializing

MemberSignatureDescription
pbn.serialize(deal: PBNDeal, options?) => stringHeader (% PBN 2.1, % EXPORT, % Content-type: text/x-pbn; charset=UTF-8, then options.header), tags, custom tags with their table rows, review as a block comment, [Auction] with notes, [Play] with notes.
pbn.serializeMulti(entries: readonly MultiDealEntry[], options?: PBNMultiSerializeOptions) => stringOne header, then each deal separated by a blank line. board defaults to the 1-based index; event and site fall back to the file-level options; customTags are merged, per-deal values winning.
pbnCreatorHeader(creator: string, created: Date) => string[]["% Creator: <creator>", "% Created: Fri 2 Oct 2026 09:05:07 +0200"].
pbn.serializeHand(hands: HandsData) => stringN:S.H.D.C S.H.D.C S.H.D.C S.H.D.C, ranks within each suit sorted high to low, a void suit left empty. Shorthand for handsToPBN(hands, "N").

Forwarding and overriding

Without options, pbn.serialize writes the deal's own values, so a deal read by pbn.parseFile is written back as read: [Board] from name; [Event], [Site] and [Date] from customTags, each written once; tags with an empty value; and the data rows of table tags, from tableRows. Parsing the output again gives the same PBNDeal. Each option replaces the matching value: an export that brands its files passes a preset of overrides (creator lines in header, site, the export date), and a renumbering passes board. The serializer writes no creator or date of its own.

What a PBNDeal does not model is not kept: every comment of a deal is in review and is written as one block before the auction, and file-level directives other than the header lines are not part of a deal.

Options accepted by pbn.serialize (the type itself is not exported):

Prop

Type

Prop

Type

Prop

Type

pbn.ts
// Forward: the deal's own Board, Event, Site, Date and custom tags
const forwarded = pbn.serialize(pbnDeal);

// Override: a brand preset plus per-export values
const brand = {
  header: pbnCreatorHeader("Bridge club tools 1.0", new Date("2026-01-15")),
  site: "Bridge club",
  date: new Date("2026-01-15"),
};
const text = pbn.serialize(pbnDeal, {
  ...brand,
  board: "1",
  event: "Lesson 3",
  customTags: { North: "Teacher", South: "Student" },
});

const entries: MultiDealEntry[] = [
  { deal: pbnDeal },
  { deal: new PBNDealBuilder().build(), options: { board: "7" } },
];
const multi = pbn.serializeMulti(entries, { event: "Lesson 3" });
pbn.ts
pbn.serializeHand({
  N: { S: ["A", "K"], H: ["Q"], D: ["T", "9"], C: ["2"] },
  E: { S: ["J"], H: ["A", "K"], D: ["8"], C: ["A", "K"] },
  S: { S: ["Q", "T"], H: ["J"], D: ["A"], C: ["Q", "J"] },
  W: { S: ["9"], H: ["T", "9"], D: ["K", "Q"], C: ["T"] },
}); // => "N:AK.Q.T9.2 J.AK.8.AK QT.J.A.QJ 9.T9.KQ.T"

Comment pre-processing

BridgeComposer writes \n escapes and \S \H \D \C suit escapes in comments. Both functions are (text: string) => string.

FunctionDescription
preprocessPbnComment\n becomes <br> (absorbing the redundant file newline that follows it), suit escapes become ♠ ♥ ♦ ♣. Real newlines are left alone, as PBN wraps lines freely.
pbn.ts
preprocessPbnComment("Lead \\SQ"); // => "Lead ♠Q"
preprocessPbnComment("Declarer\\n\nNext"); // => "Declarer<br>Next"

Result types

Prop

Type

PBNAuction and PBNPlay share the same shape: first is the dealer (auction) or the opening leader (play), and each seat array is in column order. For play, pbnPlay[seat][t] is the card that seat played in trick t, not the chronological sequence; pbn.toDeal re-keys it against each trick winner.

Prop

Type

Prop

Type

Prop

Type

Prop

Type

engine.pbn

engine.pbn exposes the lower-level functions behind the pbn object. It does not include fromDeal or serializeMulti.

MemberSignatureDescription
parsePBNFile(file: File) => Promise<ParsePBNFileResult>pbn.parseFile.
convertPBNDealToDealsame overloads as pbn.toDealpbn.toDeal.
serializePBNFile(deal: PBNDeal, options?) => stringpbn.serialize.
handsToPBNsee HandsDeal strings from any starting player; pbn.serializeHand is its North form.
createEmptyDeal() => PBNDealAll tags undefined, empty hands.
createEmptyPlayerHands() => Record<Player, Record<Suit, string>>Four empty hands.
detectDealComments(deal: Deal<string, string, string>) => DealCommentSummary{ hasAuctionComments, hasPlayComments, hasReview, hasAny }.
detectDealsComments(deals: Deal<string, string, string>[]) => DealCommentSummarySame, aggregated over a list.
detectFileEncoding(bytes: Uint8Array) => SupportedEncodingBOM, then strict UTF-8, then ISO-8859-1.
decodeBytes(bytes: Uint8Array, encoding: string) => stringDecode with fallback to ISO-8859-1, BOM removed.
removeBOM(content: string) => stringStrips a leading U+FEFF.
toUTF8(str: string) => stringRepairs latin1-as-UTF-8 mojibake; returns the input when it cannot.
pbn.ts
const engine = createBridgeEngine(
  createBridgeContext({ teachingMode: "auction", locale: "en" }),
);

engine.pbn.detectFileEncoding(new Uint8Array([0xef, 0xbb, 0xbf, 0x25])); // => "UTF-8"
engine.pbn.removeBOM("% PBN"); // => "% PBN"
engine.pbn.createEmptyDeal().cards.N; // => { S: "", H: "", D: "", C: "" }

const played = makeDeal<string, string, string>({ review: "Well bid." });
engine.pbn.detectDealComments(played); // => { hasAuctionComments: false, hasPlayComments: false, hasReview: true, hasAny: true }
engine.pbn.serializePBNFile(pbn.fromDeal(played), { includeHeader: false });

Fixtures

Sample files used by the package tests, under packages/bridge/__tests__/fixtures/pbn/:

minimal.pbn
simple-deal.pbn
full-deal.pbn
multi-deals.pbn
with-auction.pbn
with-play.pbn
with-notes.pbn
with-comments.pbn
with-teaching.pbn
real-2nd-hand.pbn
real-level-02-4ff.pbn
real-my-deals.pbn
lesson-381-Level-Three-1756-1770-export.pbn
real-les-reveils.dup
real-mercredi-1er-avril.dup