mcp-recipes 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,10 @@ var ENGLISH_FOODS = /\b(?:eggs?|salt|pepper|sugar|flour|butter|milk|cream|oil|ga
7
7
  var STRUCTURAL = 3;
8
8
  var VOCABULARY = 2;
9
9
  var NAME = 1;
10
- function readLanguage(line, evidence = { frenchUnit: false, englishUnit: false }) {
10
+ function readLanguage(line, evidence = {
11
+ frenchUnit: false,
12
+ englishUnit: false
13
+ }) {
11
14
  let french = 0;
12
15
  let english = 0;
13
16
  if (FRENCH_WORDS.test(line)) french += STRUCTURAL;
@@ -330,8 +333,10 @@ var EMBEDDED = {
330
333
  };
331
334
  function buildEmbedded(language) {
332
335
  const keys = UNIT_KEYS[language].map((key) => key.replace(/ /g, "\\s+"));
333
- return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
336
+ const joiner = "(?:(?:de|du|des|d|of|a|an|the)\\s+)?";
337
+ return new RegExp(`\\d[\\d.,/]*\\s*${joiner}(?:${keys.join("|")})\\b`, "i");
334
338
  }
339
+ var CONTAINER_NOUN = /^(?:cans?|tins?|jars?|packets?|packages?|packs?|box|boxes|bottles?|cartons?|bags?|tubs?|sachets?|blocks?|blocs?|boites?|pots?|briques?|bocaux|bocals?|barquettes?)\b/i;
335
340
  function hasEmbeddedMeasure(text, language) {
336
341
  return EMBEDDED[language].test(normalizeUnitKey(text));
337
342
  }
@@ -476,15 +481,24 @@ function chooseReadableUnit(unit, amount) {
476
481
  current = step.unit;
477
482
  }
478
483
  const up = PROMOTIONS[normalizeUnitKey(current.canonical)];
479
- if (up && amount * ratio >= up.per) {
484
+ if (up && amount * ratio >= up.per && writesExactly(amount * ratio / up.per)) {
480
485
  const target = lookupUnit(up.to, up.language);
481
486
  if (target) {
482
487
  ratio /= up.per;
483
488
  current = target;
484
489
  }
485
490
  }
491
+ while (!writesExactly(amount * ratio)) {
492
+ const step = demoteUnit(current);
493
+ if (!step) break;
494
+ ratio *= step.per;
495
+ current = step.unit;
496
+ }
486
497
  return { unit: current, ratio };
487
498
  }
499
+ function writesExactly(value) {
500
+ return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
501
+ }
488
502
  function formatUnit(unit, amount, language) {
489
503
  if (unit.symbol) return unit.canonical;
490
504
  const singular = language === "fr" ? amount < 2 : amount <= 1;
@@ -568,6 +582,40 @@ function isStatedSize(text) {
568
582
  if (!measure.unit || measure.unit.kind !== "measured") return false;
569
583
  return !CONTAINER_CONTENTS.test(measure.rest);
570
584
  }
585
+ function takeCompoundMember(text, unit, language) {
586
+ if (!unit) return null;
587
+ const step = demoteUnit(unit);
588
+ if (!step) return null;
589
+ const joiner = ADDS_UP.exec(text);
590
+ if (joiner) text = text.slice(joiner[0].length);
591
+ const second = parseLeadingQuantity(text, language);
592
+ if (!second || second.amount <= 0 || second.amount >= step.per) return null;
593
+ const after = text.slice(second.length);
594
+ const measure = takeUnit(after.trimStart(), language);
595
+ if (measure.unit) {
596
+ if (measure.unit.canonical !== step.unit.canonical) return null;
597
+ return { adds: second.amount / step.per, rest: measure.rest.trimStart() };
598
+ }
599
+ if (unit.kind !== "measured") return null;
600
+ if (!Number.isInteger(second.amount)) return null;
601
+ const trailing = after.trimStart();
602
+ if (trailing !== "" && !/^(?:de\s|d'|du\s|des\s|,)/i.test(trailing)) return null;
603
+ return { adds: second.amount / step.per, rest: trailing };
604
+ }
605
+ var ADDS_UP = /^\s*(?:\+|et|and|plus)\s+/i;
606
+ var LEADING_DECORATION = /^(?:(?!~|≈|\()[^\p{L}\p{N}\s])+(?=\s)/u;
607
+ function takeDecoration(text) {
608
+ const match = LEADING_DECORATION.exec(text);
609
+ return match ? match[0] : null;
610
+ }
611
+ function namesContainer(word) {
612
+ return word !== void 0 && CONTAINER_NOUN.test(normalizeUnitKey(word));
613
+ }
614
+ var TIME_UNIT = {
615
+ en: /^(?:h|hr|hrs|hours?|mins?|minutes?|secs?|seconds?|days?|nights?|weeks?)\b/i,
616
+ fr: /^(?:h|mn|heures?|mins?|minutes?|secs?|secondes?|jours?|nuits?|semaines?)\b/i
617
+ };
618
+ var ORDINAL_SUFFIX = /^(?:ers?|[eè]res?|[eè]mes?|es?|st|nd|rd|th)\b/i;
571
619
  var PER_PERSON = {
572
620
  en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
573
621
  fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
@@ -690,16 +738,16 @@ function parseLeadingRange(text, language) {
690
738
  length: low.length + separator[0].length + high.length
691
739
  };
692
740
  }
693
- var FRENCH_ARTICLES = { un: 1, une: 1, quelques: 3 };
741
+ var FRENCH_ARTICLES = { un: 1, une: 1 };
694
742
  function takeUnit(text, language) {
695
743
  const normalized = normalizeUnitKey(text);
696
744
  for (const key of unitKeys(language)) {
697
745
  if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
698
746
  const unit = lookupUnit(key, language);
699
747
  if (!unit) continue;
700
- const wordCount = key.split(" ").length;
701
- const words = text.trim().split(/\s+/);
702
- return { unit, rest: words.slice(wordCount).join(" ") };
748
+ const rest = afterKey(text, key);
749
+ if (rest === null) continue;
750
+ return { unit, rest };
703
751
  }
704
752
  if (language === "en") {
705
753
  const words = text.trimStart().split(/\s+/);
@@ -708,6 +756,14 @@ function takeUnit(text, language) {
708
756
  }
709
757
  return { unit: null, rest: text };
710
758
  }
759
+ function afterKey(text, key) {
760
+ const words = text.trim().split(/\s+/);
761
+ for (let count = 1; count <= words.length; count += 1) {
762
+ if (normalizeUnitKey(words.slice(0, count).join(" ")) !== key) continue;
763
+ return words.slice(count).join(" ");
764
+ }
765
+ return null;
766
+ }
711
767
  function takeUnitEitherLanguage(text, language) {
712
768
  const first = takeUnit(text, language);
713
769
  if (first.unit) return first;
@@ -723,13 +779,16 @@ function readEvidence(line) {
723
779
  }
724
780
  function parseIngredient(line, choice = "auto") {
725
781
  const original = line;
726
- const text = readable(line);
782
+ const marked = readable(line);
783
+ const decoration = takeDecoration(marked);
784
+ const text = decoration ? marked.slice(decoration.length).trimStart() : marked;
727
785
  const evidence = readEvidence(text);
728
786
  const language = choice === "auto" ? evidence.language : choice;
729
787
  const empty = (heldBack) => ({
730
788
  original,
731
789
  language,
732
790
  heldBack,
791
+ decoration,
733
792
  approximation: null,
734
793
  measureAdjective: null,
735
794
  amount: null,
@@ -738,6 +797,8 @@ function parseIngredient(line, choice = "auto") {
738
797
  unit: null,
739
798
  alternates: [],
740
799
  alternateStyle: null,
800
+ alternateIntro: null,
801
+ capacity: null,
741
802
  item: text,
742
803
  articleWord: null,
743
804
  countMultiplier: null
@@ -749,7 +810,10 @@ function parseIngredient(line, choice = "auto") {
749
810
  const article = range ? null : readArticle(stated, language);
750
811
  const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
751
812
  if (!quantity) return empty(null);
752
- if (new RegExp("^-\\p{L}", "u").test(stated.slice(quantity.length))) return empty("sizeQualifier");
813
+ const behindFigure = stated.slice(quantity.length);
814
+ if (new RegExp("^-\\p{L}", "u").test(behindFigure)) return empty("sizeQualifier");
815
+ if (quantity !== article && ORDINAL_SUFFIX.test(behindFigure)) return empty(null);
816
+ if (TIME_UNIT[language].test(behindFigure.trimStart())) return empty("duration");
753
817
  let rest = stated.slice(quantity.length).trimStart();
754
818
  if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
755
819
  const multiplier = readCountMultiplier(rest);
@@ -761,26 +825,44 @@ function parseIngredient(line, choice = "auto") {
761
825
  const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
762
826
  const leading = direct.unit ? direct : behind?.unit ? behind : direct;
763
827
  rest = leading.rest;
764
- const bracketed = takeAlternates(rest, language);
765
- rest = bracketed.rest;
828
+ const compound = range ? null : takeCompoundMember(rest, leading.unit, language);
829
+ if (compound) rest = compound.rest;
830
+ const whole = quantity.amount + (compound?.adds ?? 0);
831
+ const introducedBracket = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i.exec(rest);
832
+ const bracketed = takeAlternates(
833
+ introducedBracket ? rest.slice(introducedBracket[0].length) : rest,
834
+ language
835
+ );
836
+ rest = bracketed.measures.length > 0 ? bracketed.rest : rest;
766
837
  const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
767
838
  if (slashed) rest = slashed.rest;
768
- const item = stripItemLead(rest, language);
839
+ let item = stripItemLead(rest, language);
840
+ const trailing = bracketed.measures.length === 0 && !slashed ? takeTrailingAlternates(item, language) : null;
841
+ if (trailing) item = trailing.item;
842
+ const group = trailing ?? (bracketed.measures.length > 0 ? bracketed : null);
843
+ const capacity = group !== null && group.measures.every((measure) => measure.unit?.kind === "measured") && (namesContainer(leading.unit?.canonical) || namesContainer(item)) ? group.published : null;
769
844
  if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
770
845
  if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
846
+ if (leading.unit?.kind === "measured" && CONTAINER_NOUN.test(normalizeUnitKey(item))) {
847
+ return empty("containerSize");
848
+ }
849
+ const alternates = capacity !== null ? [] : slashed?.measures ?? group?.measures ?? [];
771
850
  return {
772
851
  original,
773
852
  language,
774
853
  heldBack: PER_PERSON[language].test(text) ? "perPerson" : null,
854
+ decoration,
775
855
  approximation: loose ? loose[0] : null,
776
856
  measureAdjective: behind?.unit ? described.adjective : null,
777
- amount: quantity.amount * times,
857
+ amount: whole * times,
778
858
  amountMax: range === null ? null : range.max * times,
779
859
  rangeSeparator: range?.separator ?? null,
780
860
  unit: leading.unit,
781
- alternates: slashed ? slashed.measures : bracketed.measures,
782
- alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
783
- item: stripItemLead(rest, language),
861
+ alternates,
862
+ alternateStyle: alternates.length === 0 ? null : slashed ? "slash" : trailing ? "trailing" : "bracket",
863
+ alternateIntro: capacity !== null ? null : group?.intro ?? null,
864
+ capacity,
865
+ item,
784
866
  articleWord: fromArticle ? article?.word ?? null : null,
785
867
  countMultiplier: multiplier?.times ?? null
786
868
  };
@@ -796,7 +878,7 @@ function unreadableComma(text, language, evidence) {
796
878
  function readArticle(text, language) {
797
879
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
798
880
  if (language === "fr") {
799
- const match = /^\s*(un|une|quelques)\b\s*/i.exec(text);
881
+ const match = /^\s*(un|une)\b\s*/i.exec(text);
800
882
  if (!match) return null;
801
883
  if (!counts(text.slice(match[0].length))) return null;
802
884
  const word = match[1];
@@ -836,18 +918,35 @@ function stripItemLead(text, language) {
836
918
  return text.replace(/^of\s+/i, "").replace(/^an?\s+/i, "").trim();
837
919
  }
838
920
  function takeAlternates(text, language) {
839
- if (!text.startsWith("(")) return { measures: [], rest: text };
921
+ const none = { measures: [], intro: null, published: "", rest: text };
922
+ if (!text.startsWith("(")) return none;
840
923
  const close = text.indexOf(")");
841
- if (close < 0) return { measures: [], rest: text };
842
- const inside = text.slice(1, close);
843
- const parts = inside.split("/").map((part) => part.trim());
924
+ if (close < 0) return none;
925
+ const read = readBracket(text.slice(0, close + 1), language);
926
+ if (!read) return none;
927
+ return { ...read, rest: text.slice(close + 1).trimStart() };
928
+ }
929
+ var EQUIVALENT_INTRODUCER = /^(?:soit|environ|about|approx\.?|approximately|or|ou|=)\s*/i;
930
+ var MEASURE_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+|of\s+)/i;
931
+ function takeMeasureAfterQuantity(text, language) {
932
+ const direct = takeUnitEitherLanguage(text, language);
933
+ if (direct.unit) return direct;
934
+ const partitive = MEASURE_PARTITIVE.exec(text);
935
+ if (!partitive) return direct;
936
+ return takeUnitEitherLanguage(text.slice(partitive[0].length), language);
937
+ }
938
+ function readBracket(bracket, language) {
939
+ const inside = bracket.slice(1, -1);
940
+ const intro = EQUIVALENT_INTRODUCER.exec(inside);
941
+ const body = intro ? inside.slice(intro[0].length) : inside;
942
+ const parts = body.split(/(?<!\d)\/|\/(?!\d)/).map((part) => part.trim());
844
943
  const measures = [];
845
944
  for (const part of parts) {
846
945
  const range = parseLeadingRange(part, language);
847
946
  const quantity = range ?? parseLeadingQuantity(part, language);
848
- if (!quantity) return { measures: [], rest: text };
849
- const after = takeUnitEitherLanguage(part.slice(quantity.length).trimStart(), language);
850
- if (!after.unit || after.rest.trim() !== "") return { measures: [], rest: text };
947
+ if (!quantity) return null;
948
+ const after = takeMeasureAfterQuantity(part.slice(quantity.length).trimStart(), language);
949
+ if (!after.unit || after.rest.trim() !== "") return null;
851
950
  measures.push({
852
951
  amount: quantity.amount,
853
952
  amountMax: range?.max ?? null,
@@ -855,8 +954,17 @@ function takeAlternates(text, language) {
855
954
  unit: after.unit
856
955
  });
857
956
  }
858
- if (measures.length === 0) return { measures: [], rest: text };
859
- return { measures, rest: text.slice(close + 1).trimStart() };
957
+ if (measures.length === 0) return null;
958
+ return { measures, intro: intro ? intro[0].trim() : null, published: bracket };
959
+ }
960
+ function takeTrailingAlternates(item, language) {
961
+ const closing = /\s*(\([^()]*\))\s*$/.exec(item);
962
+ if (!closing) return null;
963
+ const read = readBracket(closing[1], language);
964
+ if (!read) return null;
965
+ const head = item.slice(0, closing.index).trim();
966
+ if (head === "") return null;
967
+ return { ...read, item: head };
860
968
  }
861
969
  function takeSlashAlternates(text, language) {
862
970
  const measures = [];
@@ -907,6 +1015,7 @@ function roundTo(value, step) {
907
1015
  return Math.round(Math.round(value / step) * step * 100) / 100;
908
1016
  }
909
1017
  function roundMeasured(value) {
1018
+ if (Number.isInteger(value)) return value;
910
1019
  if (value >= 100) return roundTo(value, 5);
911
1020
  if (value >= 10) return roundTo(value, 1);
912
1021
  if (value >= 1) return roundTo(value, 0.1);
@@ -980,6 +1089,10 @@ function landedExactly(exact, amount) {
980
1089
  if (gap > EXACT_WITHIN) return false;
981
1090
  return exact === 0 || gap / Math.abs(exact) <= EXACT_SHARE;
982
1091
  }
1092
+ function boundLandedExactly(bound) {
1093
+ const fine = bound.ratio >= 1 ? bound.ratio : 1;
1094
+ return landedExactly(bound.raw * fine, bound.amount * (fine / bound.ratio));
1095
+ }
983
1096
  function scaleMeasure(low, high, unit, factor, divisibility) {
984
1097
  const raws = high === null ? [low * factor] : [low * factor, high * factor];
985
1098
  const sources = high === null ? [low] : [low, high];
@@ -995,7 +1108,8 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
995
1108
  amount: Math.min(usable, ceiling),
996
1109
  exact,
997
1110
  clamped: false,
998
- raw
1111
+ raw,
1112
+ ratio
999
1113
  };
1000
1114
  }),
1001
1115
  unit: target
@@ -1010,14 +1124,14 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1010
1124
  const bounds2 = raws.map((raw, index) => {
1011
1125
  const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1012
1126
  const rounded = roundSpoon(raw, ceiling);
1013
- return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
1127
+ return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
1014
1128
  });
1015
1129
  return { bounds: bounds2, unit };
1016
1130
  }
1017
1131
  const bounds = raws.map((raw, index) => {
1018
1132
  const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1019
1133
  const rounded = roundCountable(raw, divisibility, ceiling);
1020
- return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
1134
+ return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
1021
1135
  });
1022
1136
  return { bounds, unit };
1023
1137
  }
@@ -1146,6 +1260,8 @@ var IRREGULAR_SINGULAR = Object.fromEntries(
1146
1260
  Object.entries(IRREGULAR_PLURAL).map(([one, many]) => [many, one])
1147
1261
  );
1148
1262
  function matchCase(source, replacement) {
1263
+ const hasLetter = new RegExp("\\p{L}", "u").test(source);
1264
+ if (hasLetter && source === source.toUpperCase()) return replacement.toUpperCase();
1149
1265
  if (source[0] === source[0]?.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
1150
1266
  return replacement[0].toUpperCase() + replacement.slice(1);
1151
1267
  }
@@ -1194,6 +1310,15 @@ function agreeInEnglish(item, amount) {
1194
1310
  else return item;
1195
1311
  return `${words.join(" ")}${tail}`;
1196
1312
  }
1313
+ var FRENCH_OU_PLURAL_IN_X = /* @__PURE__ */ new Set([
1314
+ "bijou",
1315
+ "caillou",
1316
+ "chou",
1317
+ "genou",
1318
+ "hibou",
1319
+ "joujou",
1320
+ "pou"
1321
+ ]);
1197
1322
  var INVARIABLE_FRENCH_NOUN = /* @__PURE__ */ new Set([
1198
1323
  "ananas",
1199
1324
  "anis",
@@ -1271,15 +1396,18 @@ function agreeInFrench(item, amount) {
1271
1396
  const head = words[0] ?? "";
1272
1397
  if (head.length <= 3) return item;
1273
1398
  const wantsPlural = amount >= 2;
1274
- const isPlural = /s$|eaux$|aux$/i.test(head);
1399
+ const ouPlural = /x$/i.test(head) && FRENCH_OU_PLURAL_IN_X.has(foldWord(head.slice(0, -1)));
1400
+ const isPlural = /s$|eaux$|aux$/i.test(head) || ouPlural;
1275
1401
  if (wantsPlural && !isPlural) {
1276
1402
  if (/[sxz]$/i.test(head)) {
1277
1403
  } else if (/eau$/i.test(head)) words[0] = `${head}x`;
1404
+ else if (FRENCH_OU_PLURAL_IN_X.has(foldWord(head))) words[0] = `${head}x`;
1278
1405
  else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
1279
1406
  else words[0] = `${head}s`;
1280
1407
  } else if (!wantsPlural && isPlural) {
1281
1408
  if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
1282
1409
  else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
1410
+ else if (ouPlural) words[0] = head.slice(0, -1);
1283
1411
  else if (INVARIABLE_FRENCH_NOUN.has(foldWord(head))) {
1284
1412
  } else words[0] = head.slice(0, -1);
1285
1413
  }
@@ -1303,6 +1431,12 @@ function agreeLeadingAdjective(word, amount, language) {
1303
1431
  function agreeWithAmount(item, amount, language) {
1304
1432
  return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
1305
1433
  }
1434
+ function agreeCountedContainer(item, amount, language) {
1435
+ if (language === "fr") return agreeInFrench(item, amount);
1436
+ const space = item.indexOf(" ");
1437
+ if (space < 0) return agreeInEnglish(item, amount);
1438
+ return `${agreeInEnglish(item.slice(0, space), amount)}${item.slice(space)}`;
1439
+ }
1306
1440
  var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
1307
1441
  function joinItem(item, language) {
1308
1442
  if (!item) return "";
@@ -1361,7 +1495,9 @@ function scaleAlternative(tail, options) {
1361
1495
  var HELD_BACK_NOTE = {
1362
1496
  sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
1363
1497
  itemSize: "The measure standing behind the item gives the size of one of them rather than how many, so the line is left as published. Serving more people is a matter of taking a bigger one, and serving fewer a smaller one.",
1498
+ containerSize: "The measure here gives what one container holds rather than how many of them the recipe uses, and the line states no count, so it is left as published. Scaling it is a matter of opening that many containers of the size named.",
1364
1499
  perPerson: "This line already states an amount for one person, and the factor is what changes how many people the recipe serves, so the line is left as published.",
1500
+ duration: "This line states a length of time rather than an amount of an ingredient, so it is left as published. A rest, a proof or a bake takes as long for a large batch as for a small one, and the factor has nothing to say about it.",
1365
1501
  ambiguousDecimal: "The comma in this number marks thousands in one convention and the decimal point in another, and the line gives no sign which was meant, so it is left as published."
1366
1502
  };
1367
1503
  function scaleSingleLine(line, options) {
@@ -1385,10 +1521,12 @@ function scaleSingleLine(line, options) {
1385
1521
  const alternates = parsed.alternates.map((measure) => renderMeasure(measure, factor, language));
1386
1522
  const primaryBounds = primary.bounds;
1387
1523
  const alternateBounds = alternates.flatMap((entry) => entry.bounds);
1388
- const movedPrimary = primaryBounds.some((b) => !landedExactly(b.exact, b.amount));
1389
- const movedAlternate = alternateBounds.some((b) => !landedExactly(b.exact, b.amount));
1524
+ const movedPrimary = primaryBounds.some((bound) => !boundLandedExactly(bound));
1525
+ const movedAlternate = alternateBounds.some((bound) => !boundLandedExactly(bound));
1390
1526
  const clamped = [...primaryBounds, ...alternateBounds].find((bound) => bound.clamped) ?? null;
1391
1527
  const restated = parsed.alternateStyle === "slash";
1528
+ const carriesMore = hasEmbeddedMeasure(parsed.item, language);
1529
+ const leftover = carriesMore && parsed.unit?.kind === "measured";
1392
1530
  const low = primaryBounds[0];
1393
1531
  const high = primaryBounds[1] ?? null;
1394
1532
  const unit = primary.unit;
@@ -1404,13 +1542,17 @@ function scaleSingleLine(line, options) {
1404
1542
  const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
1405
1543
  const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
1406
1544
  const alternateTexts = alternates.map((entry) => entry.text);
1407
- const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
1408
- const counted = agreeWithAmount(parsed.item, shown, language);
1545
+ const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
1546
+ const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
1547
+ const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : parsed.alternateStyle === "trailing" ? "" : bracketed;
1548
+ const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
1549
+ const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
1550
+ const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
1409
1551
  const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
1410
1552
  const result = {
1411
- text: `${parsed.approximation ?? ""}${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
1553
+ text: `${parsed.decoration ? `${parsed.decoration} ` : ""}${parsed.approximation ?? ""}${amountText}${unitLabel}${capacityLabel}${altLabel}${itemLabel}${trailingLabel}`.trim(),
1412
1554
  original: parsed.original,
1413
- scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
1555
+ scaling: movedPrimary || movedAlternate || restated || leftover ? "rounded" : "scaled",
1414
1556
  amount: low.amount,
1415
1557
  amountMax: collapsed ? null : high?.amount ?? null,
1416
1558
  unit: named?.canonical ?? null,
@@ -1423,7 +1565,7 @@ function scaleSingleLine(line, options) {
1423
1565
  `Clamped up to ${formatAmount(clamped.amount, language)} from ${formatAmount(Math.round(clamped.raw * 1e3) / 1e3, language)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`
1424
1566
  );
1425
1567
  } else if (movedPrimary) {
1426
- const moved = primaryBounds.filter((bound) => !landedExactly(bound.exact, bound.amount));
1568
+ const moved = primaryBounds.filter((bound) => !boundLandedExactly(bound));
1427
1569
  sentences.push(
1428
1570
  moved.map(
1429
1571
  (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
@@ -1438,11 +1580,16 @@ function scaleSingleLine(line, options) {
1438
1580
  "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
1439
1581
  );
1440
1582
  }
1441
- if (hasEmbeddedMeasure(parsed.item, language)) {
1583
+ if (carriesMore) {
1442
1584
  sentences.push(
1443
1585
  "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
1444
1586
  );
1445
1587
  }
1588
+ if (parsed.capacity) {
1589
+ sentences.push(
1590
+ `${parsed.capacity} is what one container holds, so it stays as published and the count is what the factor moved. Scaling this line is a matter of opening that many containers of the size named.`
1591
+ );
1592
+ }
1446
1593
  if (collapsed) {
1447
1594
  sentences.push("The page gave a range, and at this size both ends come to the same amount.");
1448
1595
  }