package com.mousetech.gourmetj; import java.text.DecimalFormat; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.mousetech.gourmetj.persistence.model.Ingredient; /** * Service for taking an ingredients line, parsing it and * returning a populated Ingredients object. Understands fraction * characters in the amounts fields. * * @author timh * @since Dec 1, 2021 TestedBy @see IngredientDigesterTest */ public class IngredientDigester { /** * @author timh * @since Dec 5, 2021 */ public enum IngredientAmountFormat { IA_DECIMAL, // 5.75 IA_TEXT, // 5 3/4 IA_SYMBOLS, // 5¾ } final static String amountPatternTxt = "([0-9¼⅓½⅔¾⅛⅜⅝⅞][\\s\\-\\.]*[/0-9¼⅓½⅔¾⅛⅜⅝⅞]*)"; final static Pattern amountPattern = Pattern.compile(amountPatternTxt); final static Pattern fractionPattern = Pattern.compile("(\\d*)[\\s\\-]*(\\d+)/(\\d+)"); /** * Digest an ingredient text line and construct an Ingredient * object * * @param inputText Input text line * @return Ingredient */ public static Ingredient digest(String inputText) { inputText = inputText.trim(); final boolean optional = inputText.toLowerCase().contains("optional"); Ingredient ing = new Ingredient(); ing.setAmount(0.0); // Split for amount, unit and rest of string. String[] pamt = parseFancyNumber(inputText); if (pamt == null) { // Can't parse ing.setAmount(null); ing.setUnit(null); ing.setItem(inputText); } else { final Double[] amount = digestAmount(pamt[0]); ing.setAmount(amount[0]); ing.setRangeamount(amount[1]); String[] unext = pamt[1].split("\\s", 2); switch (unext.length) { case 1: ing.setUnit(null); ing.setItem(nonoise(unext[0])); break; case 2: ing.setUnit(unext[0]); ing.setItem(nonoise(unext[1])); break; } } ing.setPosition(null); ing.setOptional(optional ? 1 : 0); // Extract the item type from the item name // Assume possible prep details ("parsley, chopped") String istring = ing.getItem(); String iclass = istring.split("\\s*,",2)[0]; ing.setIngkey(iclass); return ing; } /** * Remove "noise" from ingredient title. For best results, * should be adaptable for incoming language. * * @param string Item string with noise * @return Item string with noise removed */ private static String nonoise(String string) { String xstring = string; if ( xstring.startsWith("of ")) { xstring = xstring.substring(3); } // Todo: remote "optional" from string, if present. return xstring; } /** * Break down ingredients line into 2 parts. First part is * numeric text representing amount and optional range, * second part is text remainder, including units. * * @param instring String to parse * @return 2-dimensional String array with amount and * remainder or null if instring does * not parse. * */ static String[] parseFancyNumber(String instring) { Matcher m = amountPattern.matcher(instring); if (m.find()) { // System.out.println("GROUPS=" + m.groupCount()); String rs = m.group(1); return new String[] { rs.trim(), instring.substring(m.end()).trim() }; } return null; } // TODO: Remove "optional", if present, including // brackets/parentheses /** * Convert instring into a number with optional fractions. * Can take decimals, unicode ratio characters, and n/m form * with embedded spaces and dashes. * * @param instring String to analyze * @return number equivalent pair. Note that thirds are not * precise in binary floating-point! First element is * low range amount. Second element is null unless a * high range was given as well. */ public static Double[] digestAmount(String instring) { String[] amtParts = instring.split("\\s*-\\s*"); Double[] result = new Double[2]; result[0] = parseSingleAmount(amtParts[0]); if (amtParts.length == 1) { return result; } // dash separates low, high values OR integer and a // fraction. result[1] = parseSingleAmount(amtParts[1]); if (result[1] < result[0]) { result[0] += result[1]; result[1] = 0.0d; } return result; } /** * Parse a single amount. May be integer, decimal number, * integer + fraction character or [integer] integer / * integer. * * @param instring amount string to parse * @return parsed value. * @throws NumberFormatException if bad decimal value given. */ static Double parseSingleAmount(String instring) { double value = 0; double frac = 0; if (instring.contains("/")) { // Break down a/b fraction frac = digestFraction(instring); return frac; } if (instring.contains(".")) { frac = Double.valueOf(instring); return frac; } // Look for integer and possibly a symbol; for (int i = 0; i < instring.length(); i++) { final Character ch = instring.charAt(i); switch (ch) { case '⅛': frac = 0.125d; break; case '⅓': frac = 1.0 / 3.0; break; case '¼': frac = 0.25d; break; case '⅜': frac = 3.0 * 0.125d; break; case '½': frac = 0.5d; break; case '⅝': frac = 5.0 * 0.125d; break; case '⅔': frac = 2.0 / 3.0; break; case '⅞': frac = 7.0 * 0.125d; break; default: if (Character.isDigit(ch)) { value = value * 10.0 + (ch - '0'); } // Ignore spaces, dashes, etc. break; } } value += frac; return value; } /** * Pull fraction from tail-end of string and evaluate it. * * @param instring String in the form dddxxxddd/ddd, where d * is digit, x is dash or space (optional) * @return results of evaluation. May need to throw * Exception??? */ static double digestFraction(String instring) { Matcher m = fractionPattern.matcher(instring); if (!m.find()) { return 0.0; } String lead = m.group(1); String snumerator = m.group(2); String sdenominator = m.group(3); double numerator; double denominator; try { numerator = Double.parseDouble(snumerator); denominator = Double.parseDouble(sdenominator); } catch (NumberFormatException e) { // TODO Auto-generated catch block e.printStackTrace(); return 0.0; } if (denominator == 0.0d) { return 0.0d; } double result = numerator / denominator; if (!lead.isEmpty()) { result += Double.valueOf(lead); // integral part } return result; } /** * Convert floating-point amounts to display-friendly form. * * @param format Amount formatting scheme * @param amt Amount to display * @param ext amount range high value (can be null) * @return Displayable String. Zero returns an empty string. * */ public static String displayAmount( IngredientAmountFormat format, Double amt, Double ext) { if (amt == 0.0d) { return ""; } String amountRange = displayAmountPart(format, amt); if (ext != null) { amountRange += "-" + displayAmountPart(format, ext); } return amountRange; } /** * @param format * @param amt Amount to format * @return Amount formatted in decimal, text, or text/symbol. * Values that don't conform to the common fractions * report as decimal values. Returns empty string for * 0. */ static String displayAmountPart( IngredientAmountFormat format, Double amt) { if (amt == 0.0d) { return ""; } DecimalFormat df = new DecimalFormat("#.###"); String fnum = df.format(amt); if (format == IngredientAmountFormat.IA_DECIMAL) { return fnum; } String part[] = fnum.split("\\."); if (part.length == 1) { return part[0]; // Integer } String p2 = part[1]; String fs = ""; if (format == IngredientAmountFormat.IA_TEXT) { if (p2.equals("125")) { fs = "1/8"; } else if (p2.equals("333")) { fs = "1/3"; } else if (p2.equals("25")) { fs = "1/4"; } else if (p2.equals("5")) { fs = "1/2"; } else if (p2.equals("667")) { fs = "2/3"; } else if (p2.equals("75")) { fs = "3/4"; } else { // 3, 5, 7/8 return fnum; } if (!"0".equals(part[0])) { fs = ' ' + fs; } } else { if (p2.equals("125")) { fs = "⅛"; } else if (p2.equals("333")) { fs = "⅓"; } else if (p2.equals("25")) { fs = "¼"; } else if (p2.equals("5")) { fs = "½"; } else if (p2.equals("667")) { fs = "⅔"; } else if (p2.equals("75")) { fs = "¾"; } else { // 3, 5, 7/8 return fnum; } } if ("0".equals(part[0])) { part[0] = ""; } return part[0] + fs; } }