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.
| Export | Description |
|---|---|
pbn.parseFile | Parse a PBN or DUP File into raw PBNDeal[] and domain Deal[]. |
pbn.diagnose, pbn.diagnoseFile | Parse PBN text or a PBN or DUP File and report, line by line, what the file gets wrong. |
pbn.composeFile | Write a PBN file from deals copied as written: split a file or merge several. |
pbn.toDeal | Convert one PBNDeal to a domain Deal. |
pbn.fromDeal | Convert a domain Deal back to a PBNDeal. |
pbn.serialize | Serialize one PBNDeal to PBN text. |
pbn.serializeMulti | Serialize several deals into one file. |
pbnCreatorHeader | % Creator and % Created directive lines, for the header option. |
pbn.serializeHand | PBN Deal tag value from HandsData, from North. |
preprocessPbnComment | Normalize BridgeComposer comment escapes to HTML. |
MultiDealEntry, PBNMultiSerializeOptions | Types for pbn.serializeMulti. |
PBN_DIAGNOSTIC_CODE, PBN_DIAGNOSTIC_SEVERITY | Diagnostic codes and the severity of each. |
PBNDiagnosis, PBNDiagnosedDeal, PBNDiagnostic, PBNDiagnosticCode, PBNDiagnosticParams, PBNDiagnosticSeverity | Types for pbn.diagnose. |
ComposePBNOptions | Options 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.
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% PBNheader 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, joinsreviewand the section goes on after it. In sections,DblandRdblare read asXandXX, 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.diagnosereports it. - DUP (Duplimate): detected by the
.dupextension 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
| Limit | Value |
|---|---|
| Maximum file size | 10 MB, checked before reading (MaxFileSizeError) |
| Maximum deals per file | 1000, 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:
- Byte-order mark: UTF-8, UTF-16LE or UTF-16BE.
- Strict UTF-8 decode; on failure, ISO-8859-1.
- A
% … charset=<label>directive in the decoded text re-decodes the bytes with that label when it differs and produces no replacement character. Labelsutf8,latin1,iso8859-1,cp1252andutf-16are normalized to the canonical names.
The encoding finally used is returned in data.encoding.
Errors
| Class | Thrown by parseFile | Fields |
|---|---|---|
MaxFileSizeError | file larger than 10 MB | maxSize, actualSize (MB), type: "MAX_FILE_SIZE_EXCEEDED" |
PBNTooManyDealsError | more than 1000 deals | maxDeals, dealCount |
PNBAggregateError | any other failure while tokenizing or converting | details: PBNErrorDetails — { type: "parse-error", message }, { type: "too-many-deals", max, actual } or { type: "encoding-mismatch", expected, detected } |
PBNParseError, PBNEncodingMismatchError | exported, 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.
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.
| Severity | Meaning |
|---|---|
error | The deal or the file cannot be shown as written. |
warning | Shown, but a value was dropped or contradicts another. |
info | Allowed by the format, worth knowing. |
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; // => 2Codes
File-level findings are in diagnosis.diagnostics; all others are on their deal. Tricks and calls count from 1.
| Code | Severity | Params | Reported when |
|---|---|---|---|
FILE_TOO_LARGE | error | maxMB, actualMB | The file is above 10 MB; it is not read. |
FILE_UNREADABLE | error | A DUP file is corrupt, or the parser fails. | |
TOO_MANY_DEALS | error | max, count | More than 1000 deals; no deal is returned. |
NO_DEALS | error | No deal in the file. | |
CHARSET_IGNORED | warning | declared, used | A % … charset= directive names an encoding that cannot decode the file. |
LINE_IGNORED | warning | text | The tokenizer could not read the line, such as a tag without its closing bracket. |
TAG_REPEATED | warning | tag | A tag appears twice in one deal, the last one wins; usually a missing blank line between two deals. [Note] may repeat. |
DEAL_MISSING | error | No [Deal] value, or a block between blank lines with no tag at all. | |
DEAL_FORMAT | error | value | [Deal] does not start with N:, E:, S: or W:. |
DEAL_HAND_COUNT | error | count | [Deal] does not hold four hands. |
DEAL_HAND_FORMAT | error | seat | A hand does not have four suits separated by dots. |
DEAL_INVALID_RANK | error | seat, suit, rank | A character is not a rank. 10 is two invalid characters; PBN writes T. |
CARD_NOT_IN_DECK | error | seat, card | A card is outside the deck of the deal's [Teaching] mode, such as a 4 in petit-bridge. |
DUPLICATE_CARD | error | card, seats | A card is held twice. |
HAND_SIZE | error | seat, count, expected | A hand does not hold the mode's number of cards: 13, or 10 in petit-bridge. |
HAND_UNKNOWN | info | seat | A hand is -, unknown; its cards are not counted. |
BOARD_MISSING | info | No [Board]. | |
BOARD_DUPLICATE | warning | board | Another deal before this one has the same board number. |
DEALER_MISSING, VULNERABLE_MISSING | warning | No [Dealer], or no [Vulnerable]. A DUP file carries no vulnerability and never gets VULNERABLE_MISSING. | |
DEALER_INVALID, VULNERABLE_INVALID, DECLARER_INVALID, CONTRACT_INVALID, RESULT_INVALID | warning | value | The parser cannot read the tag value and drops it. |
AUCTION_DEALER_MISMATCH | warning | dealer, first | [Auction] does not start with the dealer. |
AUCTION_TOKEN_UNKNOWN | warning | token | A token of [Auction] is neither a call nor a note, AP, $n, *, + or -; the parser drops it. |
AUCTION_CALL_ILLEGAL | warning | call, index | The first call that was not legal when made: insufficient bid, double of partner, call after the auction ended. |
CONTRACT_AUCTION_MISMATCH | warning | contract, auctionContract | The auction is complete and [Contract] is not its outcome (Pass when passed out). |
DECLARER_AUCTION_MISMATCH | warning | declarer, auctionDeclarer | The auction is complete and [Declarer] is not its declarer. |
PLAY_TOKEN_UNKNOWN | warning | token | A 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_HELD | warning | trick, seat, card | The first card played by a seat that does not hold it, or no longer does. |
PLAY_SUIT_NOT_FOLLOWED | warning | trick, seat, card | The 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
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
| Member | Signature | Description |
|---|---|---|
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>) => PBNDeal | Inverse of toDeal. |
<A, P, R>(deal: Deal<A, P, R>, converters: { auctionCommentToString; playCommentToString; reviewToString }) => PBNDeal | Serializes 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.
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"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
| Member | Signature | Description |
|---|---|---|
pbn.serialize | (deal: PBNDeal, options?) => string | Header (% 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) => string | One 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) => string | N: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
// 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.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.
| Function | Description |
|---|---|
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. |
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.
| Member | Signature | Description |
|---|---|---|
parsePBNFile | (file: File) => Promise<ParsePBNFileResult> | pbn.parseFile. |
convertPBNDealToDeal | same overloads as pbn.toDeal | pbn.toDeal. |
serializePBNFile | (deal: PBNDeal, options?) => string | pbn.serialize. |
handsToPBN | see Hands | Deal strings from any starting player; pbn.serializeHand is its North form. |
createEmptyDeal | () => PBNDeal | All 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>[]) => DealCommentSummary | Same, aggregated over a list. |
detectFileEncoding | (bytes: Uint8Array) => SupportedEncoding | BOM, then strict UTF-8, then ISO-8859-1. |
decodeBytes | (bytes: Uint8Array, encoding: string) => string | Decode with fallback to ISO-8859-1, BOM removed. |
removeBOM | (content: string) => string | Strips a leading U+FEFF. |
toUTF8 | (str: string) => string | Repairs latin1-as-UTF-8 mojibake; returns the input when it cannot. |
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/: