package net.gnehzr.tnoodle.server.webscrambles; import com.itextpdf.awt.DefaultFontMapper; import com.itextpdf.awt.PdfGraphics2D; import com.itextpdf.text.BaseColor; import com.itextpdf.text.Chunk; import com.itextpdf.text.Document; import com.itextpdf.text.DocumentException; import com.itextpdf.text.Element; import com.itextpdf.text.Font; import com.itextpdf.text.Image; import com.itextpdf.text.PageSize; import com.itextpdf.text.Paragraph; import com.itextpdf.text.Phrase; import com.itextpdf.text.Rectangle; import com.itextpdf.text.pdf.BaseFont; import com.itextpdf.text.pdf.ColumnText; import com.itextpdf.text.pdf.PdfAction; import com.itextpdf.text.pdf.PdfContentByte; import com.itextpdf.text.pdf.PdfDestination; import com.itextpdf.text.pdf.PdfImportedPage; import com.itextpdf.text.pdf.PdfOutline; import com.itextpdf.text.pdf.PdfPCell; import com.itextpdf.text.pdf.PdfPTable; import com.itextpdf.text.pdf.PdfReader; import com.itextpdf.text.pdf.PdfSmartCopy; import com.itextpdf.text.pdf.PdfTemplate; import com.itextpdf.text.pdf.PdfWriter; import net.gnehzr.tnoodle.scrambles.InvalidScrambleException; import net.gnehzr.tnoodle.scrambles.Puzzle; import net.gnehzr.tnoodle.scrambles.PuzzlePlugins; import net.gnehzr.tnoodle.scrambles.ScrambleCacher; import net.gnehzr.tnoodle.svglite.Color; import net.gnehzr.tnoodle.svglite.Dimension; import net.gnehzr.tnoodle.svglite.Svg; import net.gnehzr.tnoodle.utils.BadLazyClassDescriptionException; import net.gnehzr.tnoodle.utils.LazyInstantiator; import net.gnehzr.tnoodle.utils.Utils; import net.lingala.zip4j.exception.ZipException; import net.lingala.zip4j.io.ZipOutputStream; import net.lingala.zip4j.model.ZipParameters; import net.lingala.zip4j.util.Zip4jConstants; import org.apache.batik.bridge.BridgeContext; import org.apache.batik.bridge.DocumentLoader; import org.apache.batik.bridge.GVTBuilder; import org.apache.batik.bridge.UserAgent; import org.apache.batik.bridge.UserAgentAdapter; import org.apache.batik.dom.svg.SAXSVGDocumentFactory; import org.apache.batik.gvt.GraphicsNode; import org.apache.batik.util.XMLResourceDescriptor; import org.w3c.dom.svg.SVGDocument; import javax.servlet.ServletContext; import java.awt.Graphics2D; import java.awt.geom.AffineTransform; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.StringReader; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.SortedMap; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import static net.gnehzr.tnoodle.utils.GsonUtils.GSON; import static net.gnehzr.tnoodle.utils.GwtSafeUtils.*; class ScrambleRequest { private static final Logger l = Logger.getLogger(ScrambleRequest.class.getName()); private static final String HTML_SCRAMBLE_VIEWER = "/wca/scrambleviewer.html"; private static final int MAX_SCRAMBLES_PER_PAGE = 7; private static final int SCRAMBLE_IMAGE_PADDING = 2; private static final float MAX_SCRAMBLE_FONT_SIZE = 20; private static final float MINIMUM_ONE_LINE_FONT_SIZE = 12; private static final int MIN_LINES_TO_ALTERNATE_HIGHLIGHTING = 4; private static final BaseColor HIGHLIGHT_COLOR = new BaseColor(230, 230, 230); private static final int SCRAMBLE_PADDING_VERTICAL_TOP = 3; private static final int SCRAMBLE_PADDING_VERTICAL_BOTTOM = 6; private static final int SCRAMBLE_PADDING_HORIZONTAL = 1; private static final int MAX_COUNT = 100; private static final int MAX_COPIES = 100; private static final int WCA_MAX_MOVES_FMC = 80; private static final char NON_BREAKING_SPACE = '\u00A0'; private static HashMap<String, ScrambleCacher> scrambleCachers = new HashMap<String, ScrambleCacher>(); private static SortedMap<String, LazyInstantiator<Puzzle>> puzzles; static { try { puzzles = PuzzlePlugins.getScramblers(); } catch (BadLazyClassDescriptionException e) { l.log(Level.INFO, "", e); } catch (IOException e) { l.log(Level.INFO, "", e); } } // This is here just to make GSON work. public ScrambleRequest(){} public String[] scrambles; public String[] extraScrambles = new String[0]; public Puzzle scrambler; public int copies; public String title; public boolean fmc; public HashMap<String, Color> colorScheme; // The following attributes are here purely so the scrambler ui // can pass these straight to the generated JSON we put in the // zip file. This makes it easier to align that JSON with the rounds // of a competition. public String group, event; public int round; public ScrambleRequest(String title, String scrambleRequestUrl, String seed) throws InvalidScrambleRequestException, UnsupportedEncodingException { String[] puzzle_count_copies_scheme = scrambleRequestUrl.split("\\*"); title = URLDecoder.decode(title, "utf-8"); for(int i = 0; i < puzzle_count_copies_scheme.length; i++) { puzzle_count_copies_scheme[i] = URLDecoder.decode(puzzle_count_copies_scheme[i], "utf-8"); } String countStr = ""; String copiesStr = ""; String scheme = ""; String puzzle; switch(puzzle_count_copies_scheme.length) { case 4: scheme = puzzle_count_copies_scheme[3]; case 3: copiesStr = puzzle_count_copies_scheme[2]; case 2: countStr = puzzle_count_copies_scheme[1]; case 1: puzzle = puzzle_count_copies_scheme[0]; break; default: throw new InvalidScrambleRequestException("Invalid puzzle request " + scrambleRequestUrl); } LazyInstantiator<Puzzle> lazyScrambler = puzzles.get(puzzle); if(lazyScrambler == null) { throw new InvalidScrambleRequestException("Invalid scrambler: " + puzzle); } try { this.scrambler = lazyScrambler.cachedInstance(); } catch (Exception e) { throw new InvalidScrambleRequestException(e); } ScrambleCacher scrambleCacher = scrambleCachers.get(puzzle); if(scrambleCacher == null) { scrambleCacher = new ScrambleCacher(scrambler); scrambleCachers.put(puzzle, scrambleCacher); } this.title = title; fmc = countStr.equals("fmc"); int count; if(fmc) { count = 1; } else { count = Math.min(toInt(countStr, 1), MAX_COUNT); } this.copies = Math.min(toInt(copiesStr, 1), MAX_COPIES); if(seed != null) { this.scrambles = scrambler.generateSeededScrambles(seed, count); } else { this.scrambles = scrambleCacher.newScrambles(count); } this.colorScheme = scrambler.parseColorScheme(scheme); } public List<String> getAllScrambles() { ArrayList<String> allScrambles = new ArrayList<String>(Arrays.asList(scrambles)); if(extraScrambles != null) { allScrambles.addAll(Arrays.asList(extraScrambles)); } return allScrambles; } public static ScrambleRequest[] parseScrambleRequests(LinkedHashMap<String, String> query, String seed) throws UnsupportedEncodingException, InvalidScrambleRequestException { ScrambleRequest[] scrambleRequests; if(query.size() == 0) { throw new InvalidScrambleRequestException("Must specify at least one scramble request"); } else { scrambleRequests = new ScrambleRequest[query.size()]; int i = 0; for(String title : query.keySet()) { // Note that we prefix the seed with the title of the round! This ensures that we get unique // scrambles in different rounds. Thanks to Ravi Fernando for noticing this at Stanford Fall 2011. // (http://www.worldcubeassociation.org/results/c.php?i=StanfordFall2011). String uniqueSeed = null; if(seed != null) { uniqueSeed = title + seed; } scrambleRequests[i++] = new ScrambleRequest(title, query.get(title), uniqueSeed); } } return scrambleRequests; } private static PdfReader createPdf(String globalTitle, Date creationDate, ScrambleRequest scrambleRequest) throws DocumentException, IOException { azzert(scrambleRequest.scrambles.length > 0); ByteArrayOutputStream pdfOut = new ByteArrayOutputStream(); Rectangle pageSize = PageSize.LETTER; Document doc = new Document(pageSize, 0, 0, 75, 75); PdfWriter docWriter = PdfWriter.getInstance(doc, pdfOut); docWriter.setBoxSize("art", new Rectangle(36, 54, pageSize.getWidth()-36, pageSize.getHeight()-54)); doc.addCreationDate(); doc.addProducer(); if(globalTitle != null) { doc.addTitle(globalTitle); } doc.open(); // Note that we ignore scrambleRequest.copies here. addScrambles(docWriter, doc, scrambleRequest, globalTitle); doc.close(); // TODO - is there a better way to convert from a PdfWriter to a PdfReader? PdfReader pr = new PdfReader(pdfOut.toByteArray()); if(scrambleRequest.fmc) { // We don't watermark the FMC sheets because they already have // the competition name on them. return pr; } pdfOut = new ByteArrayOutputStream(); doc = new Document(pageSize, 0, 0, 75, 75); docWriter = PdfWriter.getInstance(doc, pdfOut); doc.open(); PdfContentByte cb = docWriter.getDirectContent(); for(int pageN = 1; pageN <= pr.getNumberOfPages(); pageN++) { PdfImportedPage page = docWriter.getImportedPage(pr, pageN); doc.newPage(); cb.addTemplate(page, 0, 0); Rectangle rect = pr.getBoxSize(pageN, "art"); // Header ColumnText.showTextAligned(cb, Element.ALIGN_LEFT, new Phrase(Utils.SDF.format(creationDate)), rect.getLeft(), rect.getTop(), 0); ColumnText.showTextAligned(cb, Element.ALIGN_CENTER, new Phrase(globalTitle), (pageSize.getLeft() + pageSize.getRight()) / 2, pageSize.getTop() - 60, 0); ColumnText.showTextAligned(cb, Element.ALIGN_CENTER, new Phrase(scrambleRequest.title), (pageSize.getLeft() + pageSize.getRight()) / 2, pageSize.getTop() - 45, 0); if(pr.getNumberOfPages() > 1) { ColumnText.showTextAligned(cb, Element.ALIGN_RIGHT, new Phrase(pageN + "/" + pr.getNumberOfPages()), rect.getRight(), rect.getTop(), 0); } // Footer String generatedBy = "Generated by " + Utils.getProjectName() + "-" + Utils.getVersion(); ColumnText.showTextAligned(cb, Element.ALIGN_CENTER, new Phrase(generatedBy), (pageSize.getLeft() + pageSize.getRight()) / 2, pageSize.getBottom() + 40, 0); } doc.close(); // TODO - is there a better way to convert from a PdfWriter to a PdfReader? pr = new PdfReader(pdfOut.toByteArray()); return pr; // The PdfStamper class doesn't seem to be working. // pdfOut = new ByteArrayOutputStream(); // PdfStamper ps = new PdfStamper(pr, pdfOut); // // for(int pageN = 1; pageN <= pr.getNumberOfPages(); pageN++) { // PdfContentByte pb = ps.getUnderContent(pageN); // Rectangle rect = pr.getBoxSize(pageN, "art"); // System.out.println(rect.getLeft()); // System.out.println(rect.getWidth()); // ColumnText.showTextAligned(pb, // Element.ALIGN_LEFT, new Phrase("Hello people!"), 36, 540, 0); //// ColumnText.showTextAligned(pb, //// Element.ALIGN_CENTER, new Phrase("HELLO WORLD"), //// (rect.getLeft() + rect.getRight()) / 2, rect.getTop(), 0); // } // ps.close(); // return ps.getReader(); } private static void addScrambles(PdfWriter docWriter, Document doc, ScrambleRequest scrambleRequest, String globalTitle) throws DocumentException, IOException { if(scrambleRequest.fmc) { Rectangle pageSize = doc.getPageSize(); for(int i = 0; i < scrambleRequest.scrambles.length; i++) { String scramble = scrambleRequest.scrambles[i]; PdfContentByte cb = docWriter.getDirectContent(); float LINE_THICKNESS = 0.5f; BaseFont bf = BaseFont.createFont(BaseFont.HELVETICA, BaseFont.CP1252, BaseFont.NOT_EMBEDDED); int bottom = 30; int left = 35; int right = (int) (pageSize.getWidth()-left); int top = (int) (pageSize.getHeight()-bottom); int height = top - bottom; int width = right - left; int solutionBorderTop = bottom + (int) (height*.5); int scrambleBorderTop = solutionBorderTop + 40; int competitorInfoBottom = top - (int) (height*.15); int gradeBottom = competitorInfoBottom - 50; int competitorInfoLeft = right - (int) (width*.45); int rulesRight = competitorInfoLeft; int padding = 5; // Outer border cb.setLineWidth(2f); cb.moveTo(left, top); cb.lineTo(left, bottom); cb.lineTo(right, bottom); cb.lineTo(right, top); // Solution border cb.moveTo(left, solutionBorderTop); cb.lineTo(right, solutionBorderTop); // Rules bottom border cb.moveTo(left, scrambleBorderTop); cb.lineTo(rulesRight, scrambleBorderTop); // Rules right border cb.lineTo(rulesRight, gradeBottom); // Grade bottom border cb.moveTo(competitorInfoLeft, gradeBottom); cb.lineTo(right, gradeBottom); // Competitor info bottom border cb.moveTo(competitorInfoLeft, competitorInfoBottom); cb.lineTo(right, competitorInfoBottom); // Competitor info left border cb.moveTo(competitorInfoLeft, gradeBottom); cb.lineTo(competitorInfoLeft, top); // Solution lines int availableSolutionWidth = right - left; int availableSolutionHeight = scrambleBorderTop - bottom; int lineWidth = 25; //int linesX = (availableSolutionWidth/lineWidth + 1)/2; int linesX = 10; int linesY = (int) Math.ceil(1.0*WCA_MAX_MOVES_FMC / linesX); cb.setLineWidth(LINE_THICKNESS); cb.stroke(); // int allocatedX = (2*linesX-1)*lineWidth; int excessX = availableSolutionWidth-linesX*lineWidth; int moveCount = 0; solutionLines: for(int y = 0; y < linesY; y++) { for(int x = 0; x < linesX; x++) { if(moveCount >= WCA_MAX_MOVES_FMC) { break solutionLines; } int xPos = left + x*lineWidth + (x+1)*excessX/(linesX+1); int yPos = solutionBorderTop - (y+1)*availableSolutionHeight/(linesY+1); cb.moveTo(xPos, yPos); cb.lineTo(xPos+lineWidth, yPos); moveCount++; } } float UNDERLINE_THICKNESS = 0.2f; cb.setLineWidth(UNDERLINE_THICKNESS); cb.stroke(); cb.beginText(); int availableScrambleSpace = right-left - 2*padding; int scrambleFontSize = 20; String scrambleStr = "Scramble: " + scramble; float scrambleWidth; do { scrambleFontSize--; scrambleWidth = bf.getWidthPoint(scrambleStr, scrambleFontSize); } while(scrambleWidth > availableScrambleSpace); cb.setFontAndSize(bf, scrambleFontSize); int scrambleY = 3 + solutionBorderTop+(scrambleBorderTop-solutionBorderTop-scrambleFontSize)/2; cb.showTextAligned(PdfContentByte.ALIGN_LEFT, scrambleStr, left+padding, scrambleY, 0); cb.endText(); int availableScrambleWidth = right-rulesRight; int availableScrambleHeight = gradeBottom-scrambleBorderTop; Dimension dim = scrambleRequest.scrambler.getPreferredSize(availableScrambleWidth-2, availableScrambleHeight-2); PdfTemplate tp = cb.createTemplate(dim.width, dim.height); Graphics2D g2 = new PdfGraphics2D(tp, dim.width, dim.height, new DefaultFontMapper()); try { Svg svg = scrambleRequest.scrambler.drawScramble(scramble, scrambleRequest.colorScheme); drawSvgToGraphics2D(svg, g2, dim); } catch (InvalidScrambleException e) { l.log(Level.INFO, "", e); } finally { g2.dispose(); } cb.addImage(Image.getInstance(tp), dim.width, 0, 0, dim.height, rulesRight + (availableScrambleWidth-dim.width)/2, scrambleBorderTop + (availableScrambleHeight-dim.height)/2); ColumnText ct = new ColumnText(cb); int fontSize = 15; int marginBottom = 10; int offsetTop = 27; boolean showScrambleCount = scrambleRequest.scrambles.length > 1; if(showScrambleCount) { offsetTop -= fontSize + 2; } cb.beginText(); cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_CENTER, globalTitle, competitorInfoLeft+(right-competitorInfoLeft)/2, top-offsetTop, 0); offsetTop += fontSize + 2; cb.endText(); cb.beginText(); cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_CENTER, scrambleRequest.title, competitorInfoLeft+(right-competitorInfoLeft)/2, top-offsetTop, 0); cb.endText(); if(showScrambleCount) { cb.beginText(); offsetTop += fontSize + 2; cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_CENTER, "Scramble " + (i+1) + " of " + scrambleRequest.scrambles.length, competitorInfoLeft+(right-competitorInfoLeft)/2, top-offsetTop, 0); cb.endText(); } offsetTop += fontSize + marginBottom; cb.beginText(); fontSize = 15; cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_LEFT, "Competitor: __________________", competitorInfoLeft+padding, top-offsetTop, 0); offsetTop += fontSize + marginBottom; cb.endText(); cb.beginText(); fontSize = 15; cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_LEFT, "WCA ID:", competitorInfoLeft+padding, top-offsetTop, 0); cb.setFontAndSize(bf, 19); int wcaIdLength = 63; cb.showTextAligned(PdfContentByte.ALIGN_LEFT, "_ _ _ _ _ _ _ _ _ _", competitorInfoLeft+padding+wcaIdLength, top-offsetTop, 0); offsetTop += fontSize + (int) (marginBottom*1.8); cb.endText(); cb.beginText(); fontSize = 11; cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_CENTER, "DO NOT FILL IF YOU ARE THE COMPETITOR", competitorInfoLeft + (right-competitorInfoLeft)/2, top-offsetTop, 0); offsetTop += fontSize + marginBottom; cb.endText(); cb.beginText(); fontSize = 11; cb.setFontAndSize(bf, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_CENTER, "Graded by: _______________ Result: ______", competitorInfoLeft + (right-competitorInfoLeft)/2, top-offsetTop, 0); offsetTop += fontSize + marginBottom; cb.endText(); cb.beginText(); cb.setFontAndSize(bf, 25f); int MAGIC_NUMBER = 40; // kill me now cb.showTextAligned(PdfContentByte.ALIGN_CENTER, "Fewest Moves", left+(competitorInfoLeft-left)/2, top-MAGIC_NUMBER, 0); cb.endText(); com.itextpdf.text.List rules = new com.itextpdf.text.List(com.itextpdf.text.List.UNORDERED); rules.add("Notate your solution by writing one move per bar."); rules.add("To delete moves, clearly erase/blacken them."); rules.add("Face moves F, B, R, L, U, and D are clockwise."); rules.add("Rotations x, y, and z follow R, U, and F."); rules.add("' inverts a move; 2 doubles a move. (e.g.: U', U2)"); rules.add("w makes a face move into two layers. (e.g.: Uw)"); rules.add("A [lowercase] move is a cube rotation. (e.g.: [u])"); ct.addElement(rules); int rulesTop = competitorInfoBottom+55; ct.setSimpleColumn(left+padding, scrambleBorderTop, competitorInfoLeft-padding, rulesTop, 0, Element.ALIGN_LEFT); ct.go(); rules = new com.itextpdf.text.List(com.itextpdf.text.List.UNORDERED); rules.add("You have 1 hour to find a solution."); rules.add("Your solution length will be counted in OBTM."); int maxMoves = WCA_MAX_MOVES_FMC; rules.add("Your solution must be at most " + maxMoves + " moves, including rotations."); rules.add("Your solution must not be directly derived from any part of the scrambling algorithm."); ct.addElement(rules); MAGIC_NUMBER = 150; // kill me now ct.setSimpleColumn(left+padding, scrambleBorderTop, rulesRight-padding, rulesTop-MAGIC_NUMBER, 0, Element.ALIGN_LEFT); ct.go(); doc.newPage(); } } else { Rectangle pageSize = doc.getPageSize(); float sideMargins = 100 + doc.leftMargin() + doc.rightMargin(); float availableWidth = pageSize.getWidth()-sideMargins; float vertMargins = doc.topMargin() + doc.bottomMargin(); float availableHeight = pageSize.getHeight() - vertMargins; if(scrambleRequest.extraScrambles.length > 0) { availableHeight -= 20; // Yeee magic numbers. This should make space for the headerTable. } int scramblesPerPage = Math.min(MAX_SCRAMBLES_PER_PAGE, scrambleRequest.getAllScrambles().size()); int maxScrambleImageHeight = (int) (availableHeight/scramblesPerPage - 2*SCRAMBLE_IMAGE_PADDING); int maxScrambleImageWidth = (int) (availableWidth/2); // We don't let scramble images take up more than half the page if(scrambleRequest.scrambler.getShortName().equals("minx")) { // TODO - If we allow the megaminx image to be too wide, the // megaminx scrambles get really tiny. This tweak allocates // a more optimal amount of space to the scrambles. This is possible // because the scrambles are so uniformly sized. maxScrambleImageWidth = 190; } Dimension scrambleImageSize = scrambleRequest.scrambler.getPreferredSize(maxScrambleImageWidth, maxScrambleImageHeight); // First do a dry run just to see if any scrambles require highlighting. // Then do the real run, and force highlighting on every scramble // if any scramble required it. boolean forceHighlighting = false; for(boolean dryRun : new boolean[]{ true, false }) { String scrambleNumberPrefix = ""; TableAndHighlighting tableAndHighlighting = createTable(docWriter, doc, sideMargins, scrambleImageSize, scrambleRequest.scrambles, scrambleRequest.scrambler, scrambleRequest.colorScheme, scrambleNumberPrefix, forceHighlighting); if(dryRun) { if(tableAndHighlighting.highlighting) { forceHighlighting = true; continue; } } else { doc.add(tableAndHighlighting.table); } if(scrambleRequest.extraScrambles.length > 0) { PdfPTable headerTable = new PdfPTable(1); headerTable.setTotalWidth(new float[] { availableWidth }); headerTable.setLockedWidth(true); PdfPCell extraScramblesHeader = new PdfPCell(new Paragraph("Extra scrambles")); extraScramblesHeader.setVerticalAlignment(PdfPCell.ALIGN_MIDDLE); extraScramblesHeader.setPaddingBottom(3); headerTable.addCell(extraScramblesHeader); if(!dryRun) { doc.add(headerTable); } scrambleNumberPrefix = "E"; TableAndHighlighting extraTableAndHighlighting = createTable(docWriter, doc, sideMargins, scrambleImageSize, scrambleRequest.extraScrambles, scrambleRequest.scrambler, scrambleRequest.colorScheme, scrambleNumberPrefix, forceHighlighting); if(dryRun) { if(tableAndHighlighting.highlighting) { forceHighlighting = true; continue; } } else { doc.add(extraTableAndHighlighting.table); } } } } doc.newPage(); } /** * Copied from ColumnText.java in the itextpdf 5.3.0 source code. * Added the newlinesAllowed argument. * Fits the text to some rectangle adjusting the font size as needed. * @param font the font to use * @param text the text * @param rect the rectangle where the text must fit * @param maxFontSize the maximum font size * @return the calculated font size that makes the text fit */ private static final float FITTEXT_FONTSIZE_PRECISION = 0.1f; public static float fitText(Font font, String text, Rectangle rect, float maxFontSize, boolean newlinesAllowed) { float minFontSize = 1f; float potentialFontSize; while(true) { potentialFontSize = (maxFontSize + minFontSize) / 2.0f; font.setSize(potentialFontSize); LinkedList<Chunk> lineChunks = splitScrambleToLineChunks(text, font, rect.getWidth()); if(!newlinesAllowed && lineChunks.size() > 1) { // If newlines are not allowed, and we had to split the text into more than // one line, then potentialFontSize is too large. maxFontSize = potentialFontSize; } else { // The font size seems to be a pretty good estimate for how // much vertical space a row actually takes up. float totalHeight = lineChunks.size() * potentialFontSize; if(totalHeight < rect.getHeight()) { minFontSize = potentialFontSize; } else { maxFontSize = potentialFontSize; } } if(maxFontSize - minFontSize < FITTEXT_FONTSIZE_PRECISION) { // Err on the side of too small, because being too large will screw up // layout. potentialFontSize = minFontSize; break; } } return potentialFontSize; } private static LinkedList<Chunk> splitScrambleToLineChunks(String paddedScramble, Font scrambleFont, float scrambleColumnWidth) { float availableScrambleWidth = scrambleColumnWidth - 2*SCRAMBLE_PADDING_HORIZONTAL; int startIndex = 0; int endIndex = 0; LinkedList<Chunk> lineChunks = new LinkedList<Chunk>(); while(startIndex < paddedScramble.length()) { // Walk forwards until we've grabbed the maximum number of characters // that fit in a line, we've run out of characters, or we hit a newline. float substringWidth; for(endIndex++; endIndex <= paddedScramble.length(); endIndex++) { if(paddedScramble.charAt(endIndex - 1) == '\n') { break; } String scrambleSubstring = NON_BREAKING_SPACE + paddedScramble.substring(startIndex, endIndex) + NON_BREAKING_SPACE; substringWidth = scrambleFont.getBaseFont().getWidthPoint(scrambleSubstring, scrambleFont.getSize()); if(substringWidth > availableScrambleWidth) { break; } } // endIndex is one past the best fit, so remove one character and it should fit! endIndex--; // If we're not at the end of the scramble, make sure we're not cutting // a turn in half by walking backwards until we're right before a turn. // Any spaces added for padding after a turn are considered part of // that turn because they're actually NON_BREAKING_SPACE, not a ' '. int perfectFitEndIndex = endIndex; if(endIndex < paddedScramble.length()) { while(true) { if(endIndex < startIndex) { // We walked all the way to the beginning of the line // without finding a good breaking point. Give up and break // in the middle of a turn =(. endIndex = perfectFitEndIndex; break; } // Another dirty hack for sq1: turns only line up // nicely if every line starts with a (x,y). We ensure this // by forcing every line to end with a /. boolean isSquareOne = paddedScramble.indexOf('/') >= 0; if(isSquareOne) { char previousCharacter = paddedScramble.charAt(endIndex - 1); if(previousCharacter == '/') { break; } } else { char currentCharacter = paddedScramble.charAt(endIndex); boolean isTurnCharacter = currentCharacter != ' '; if(!isTurnCharacter || currentCharacter == '\n') { break; } } endIndex--; } } String scrambleSubstring = NON_BREAKING_SPACE + paddedScramble.substring(startIndex, endIndex) + NON_BREAKING_SPACE; // Add NON_BREAKING_SPACE until the scrambleSubstring takes up as much as // space as is available on a line. do { scrambleSubstring += NON_BREAKING_SPACE; substringWidth = scrambleFont.getBaseFont().getWidthPoint(scrambleSubstring, scrambleFont.getSize()); } while(substringWidth <= availableScrambleWidth); // scrambleSubstring is now too big for our line, so remove the // last character. scrambleSubstring = scrambleSubstring.substring(0, scrambleSubstring.length() - 1); // Walk past all whitespace that comes immediately after the line wrap // we are about to insert. while(endIndex < paddedScramble.length() && (paddedScramble.charAt(endIndex) == ' ' || paddedScramble.charAt(endIndex) == '\n' )) { endIndex++; } startIndex = endIndex; Chunk lineChunk = new Chunk(scrambleSubstring); lineChunks.add(lineChunk); lineChunk.setFont(scrambleFont); // Force a line wrap! lineChunk.append("\n"); } return lineChunks; } static class TableAndHighlighting { PdfPTable table; boolean highlighting; } private static TableAndHighlighting createTable(PdfWriter docWriter, Document doc, float sideMargins, Dimension scrambleImageSize, String[] scrambles, Puzzle scrambler, HashMap<String, Color> colorScheme, String scrambleNumberPrefix, boolean forceHighlighting) throws DocumentException { PdfContentByte cb = docWriter.getDirectContent(); PdfPTable table = new PdfPTable(3); int charsWide = scrambleNumberPrefix.length() + 1 + (int) Math.log10(scrambles.length); String wideString = ""; for(int i = 0; i < charsWide; i++) { // M has got to be as wide or wider than the widest digit in our font wideString += "M"; } wideString += "."; float col1Width = new Chunk(wideString).getWidthPoint(); // I don't know why we need this, perhaps there's some padding? col1Width += 5; float availableWidth = doc.getPageSize().getWidth() - sideMargins; float scrambleColumnWidth = availableWidth - col1Width - scrambleImageSize.width - 2*SCRAMBLE_IMAGE_PADDING; int availableScrambleHeight = scrambleImageSize.height - 2*SCRAMBLE_IMAGE_PADDING; table.setTotalWidth(new float[] { col1Width, scrambleColumnWidth, scrambleImageSize.width + 2*SCRAMBLE_IMAGE_PADDING }); table.setLockedWidth(true); String longestScramble = ""; String longestPaddedScramble = ""; for(String scramble : scrambles) { if(scramble.length() > longestScramble.length()) { longestScramble = scramble; } String paddedScramble = padTurnsUniformly(scramble, "M"); if(paddedScramble.length() > longestPaddedScramble.length()) { longestPaddedScramble = paddedScramble; } } // I don't know how to configure ColumnText.fitText's word wrapping characters, // so instead, I just replace each character I don't want to wrap with M, which // should be the widest character (we're using a monospaced font, // so that doesn't really matter), and won't get wrapped. char widestCharacter = 'M'; longestPaddedScramble = longestPaddedScramble.replaceAll("\\S", widestCharacter + ""); boolean tryToFitOnOneLine = true; if(longestPaddedScramble.indexOf("\n") >= 0) { // If the scramble contains newlines, then we *only* allow wrapping at the // newlines. longestPaddedScramble = longestPaddedScramble.replaceAll(" ", "M"); tryToFitOnOneLine = false; } boolean oneLine = false; Font scrambleFont = null; try { BaseFont courier = BaseFont.createFont("fonts/LiberationMono-Regular.ttf", BaseFont.CP1252, BaseFont.EMBEDDED); Rectangle availableArea = new Rectangle(scrambleColumnWidth - 2*SCRAMBLE_PADDING_HORIZONTAL, availableScrambleHeight - SCRAMBLE_PADDING_VERTICAL_TOP - SCRAMBLE_PADDING_VERTICAL_BOTTOM); float perfectFontSize = fitText(new Font(courier), longestPaddedScramble, availableArea, MAX_SCRAMBLE_FONT_SIZE, true); if(tryToFitOnOneLine) { String longestScrambleOneLine = longestScramble.replaceAll(".", widestCharacter + ""); float perfectFontSizeForOneLine = fitText(new Font(courier), longestScrambleOneLine, availableArea, MAX_SCRAMBLE_FONT_SIZE, false); oneLine = perfectFontSizeForOneLine >= MINIMUM_ONE_LINE_FONT_SIZE; if(oneLine) { perfectFontSize = perfectFontSizeForOneLine; } } scrambleFont = new Font(courier, perfectFontSize, Font.NORMAL); } catch(IOException e) { l.log(Level.INFO, "", e); } catch(DocumentException e) { l.log(Level.INFO, "", e); } boolean highlight = forceHighlighting; for(int i = 0; i < scrambles.length; i++) { String scramble = scrambles[i]; String paddedScramble = oneLine ? scramble : padTurnsUniformly(scramble, NON_BREAKING_SPACE + ""); Chunk ch = new Chunk(scrambleNumberPrefix + (i+1) + "."); PdfPCell nthscramble = new PdfPCell(new Paragraph(ch)); nthscramble.setVerticalAlignment(PdfPCell.ALIGN_MIDDLE); table.addCell(nthscramble); Phrase scramblePhrase = new Phrase(); int nthLine = 1; LinkedList<Chunk> lineChunks = splitScrambleToLineChunks(paddedScramble, scrambleFont, scrambleColumnWidth); if(lineChunks.size() >= MIN_LINES_TO_ALTERNATE_HIGHLIGHTING) { highlight = true; } for(Chunk lineChunk : lineChunks) { if(highlight && (nthLine % 2 == 0)) { lineChunk.setBackground(HIGHLIGHT_COLOR); } scramblePhrase.add(lineChunk); nthLine++; } PdfPCell scrambleCell = new PdfPCell(new Paragraph(scramblePhrase)); // We carefully inserted newlines ourselves to make stuff fit, don't // let itextpdf wrap lines for us. scrambleCell.setNoWrap(true); scrambleCell.setVerticalAlignment(PdfPCell.ALIGN_MIDDLE); // This shifts everything up a little bit, because I don't like how // ALIGN_MIDDLE works. scrambleCell.setPaddingTop(-SCRAMBLE_PADDING_VERTICAL_TOP); scrambleCell.setPaddingBottom(SCRAMBLE_PADDING_VERTICAL_BOTTOM); scrambleCell.setPaddingLeft(SCRAMBLE_PADDING_HORIZONTAL); scrambleCell.setPaddingRight(SCRAMBLE_PADDING_HORIZONTAL); // We space lines a little bit more here - it still fits in the cell height scrambleCell.setLeading(0, 1.1f); table.addCell(scrambleCell); if(scrambleImageSize.width > 0 && scrambleImageSize.height > 0) { PdfTemplate tp = cb.createTemplate(scrambleImageSize.width + 2*SCRAMBLE_IMAGE_PADDING, scrambleImageSize.height + 2*SCRAMBLE_IMAGE_PADDING); Graphics2D g2 = new PdfGraphics2D(tp, tp.getWidth(), tp.getHeight(), new DefaultFontMapper()); g2.translate(SCRAMBLE_IMAGE_PADDING, SCRAMBLE_IMAGE_PADDING); try { Svg svg = scrambler.drawScramble(scramble, colorScheme); drawSvgToGraphics2D(svg, g2, scrambleImageSize); } catch(Exception e) { table.addCell("Error drawing scramble: " + e.getMessage()); l.log(Level.WARNING, "Error drawing scramble, if you're having font issues, try installing ttf-dejavu.", e); continue; } finally { g2.dispose(); // iTextPdf blows up if we do not dispose of this } PdfPCell imgCell = new PdfPCell(Image.getInstance(tp), true); imgCell.setBackgroundColor(BaseColor.LIGHT_GRAY); imgCell.setVerticalAlignment(PdfPCell.ALIGN_MIDDLE); imgCell.setHorizontalAlignment(PdfPCell.ALIGN_MIDDLE); table.addCell(imgCell); } else { table.addCell(""); } } TableAndHighlighting tableAndHighlighting = new TableAndHighlighting(); tableAndHighlighting.table = table; tableAndHighlighting.highlighting = highlight; return tableAndHighlighting; } private static void drawSvgToGraphics2D(Svg svg, Graphics2D g2, Dimension size) throws IOException { // Copied (and modified) from http://stackoverflow.com/a/12502943 String parser = XMLResourceDescriptor.getXMLParserClassName(); SAXSVGDocumentFactory factory = new SAXSVGDocumentFactory(parser); UserAgent userAgent = new UserAgentAdapter(); DocumentLoader loader = new DocumentLoader(userAgent); BridgeContext ctx = new BridgeContext(userAgent, loader); ctx.setDynamicState(BridgeContext.DYNAMIC); GVTBuilder builder = new GVTBuilder(); StringReader svgReader = new StringReader(svg.toString()); SVGDocument parsedSvgDocument = factory.createSVGDocument(null, svgReader); GraphicsNode chartGfx = builder.build(ctx, parsedSvgDocument); Dimension actualSize = svg.getSize(); double scaleWidth = 1.0*size.width / actualSize.width; double scaleHeight = 1.0*size.height / actualSize.height; chartGfx.setTransform(AffineTransform.getScaleInstance(scaleWidth, scaleHeight)); chartGfx.paint(g2); } private static String padTurnsUniformly(String scramble, String padding) { azzert(scramble != null, "scramble cannot be null"); String[] turns = scramble.split("\\s+"); int maxTurnLength = 0; for(String turn : turns) { maxTurnLength = Math.max(maxTurnLength, turn.length()); } StringBuilder s = new StringBuilder(); String[] lines = scramble.split("\\n"); for(int i = 0; i < lines.length; i++) { String line = lines[i]; if(i > 0) { s.append("\n"); } turns = line.split("\\s+"); for(int j = 0; j < turns.length; j++) { String turn = turns[j]; if(j > 0) { s.append(" "); } // TODO - this is a disgusting hack for sq1. We don't pad the / // turns because they're guaranteed to occur as every other turn, // so stuff will line up nicely without padding them. I don't know // what a good general solution to this problem is. if(!turn.equals("/")) { while(turn.length() < maxTurnLength) { turn += padding; } } s.append(turn); } } return s.toString(); } private static ArrayList<String> stripNewlines(List<String> strings) { ArrayList<String> newStrings = new ArrayList<String>(); for(String newString : strings) { newStrings.add(newString.replaceAll("\n", " ")); } return newStrings; } private static final String INVALID_CHARS = "\\/:*?\"<>|"; private static String toFileSafeString(String unsafe) { for(int i = 0; i < INVALID_CHARS.length(); i++) { String invalidChar = Pattern.quote("" + INVALID_CHARS.charAt(i)); unsafe = unsafe.replaceAll(invalidChar, ""); } return unsafe; } public static ByteArrayOutputStream requestsToZip(ServletContext context, String globalTitle, Date generationDate, ScrambleRequest[] scrambleRequests, String password, String generationUrl) throws IOException, DocumentException, ZipException { ByteArrayOutputStream baosZip = new ByteArrayOutputStream(); ZipParameters parameters = new ZipParameters(); parameters.setCompressionMethod(Zip4jConstants.COMP_DEFLATE); parameters.setCompressionLevel(Zip4jConstants.DEFLATE_LEVEL_NORMAL); if(password != null) { parameters.setEncryptFiles(true); parameters.setEncryptionMethod(Zip4jConstants.ENC_METHOD_STANDARD); parameters.setPassword(password); } parameters.setSourceExternalStream(true); ZipOutputStream zipOut = new ZipOutputStream(baosZip); HashMap<String, Boolean> seenTitles = new HashMap<String, Boolean>(); for(ScrambleRequest scrambleRequest : scrambleRequests) { String safeTitle = toFileSafeString(scrambleRequest.title); int salt = 0; String tempNewSafeTitle = safeTitle; while(seenTitles.get(tempNewSafeTitle) != null) { tempNewSafeTitle = safeTitle + " (" + (++salt) + ")"; } safeTitle = tempNewSafeTitle; seenTitles.put(safeTitle, true); String pdfFileName = "pdf/" + safeTitle + ".pdf"; parameters.setFileNameInZip(pdfFileName); zipOut.putNextEntry(null, parameters); PdfReader pdfReader = createPdf(globalTitle, generationDate, scrambleRequest); byte[] b = new byte[(int) pdfReader.getFileLength()]; pdfReader.getSafeFile().readFully(b); zipOut.write(b); zipOut.closeEntry(); String txtFileName = "txt/" + safeTitle + ".txt"; parameters.setFileNameInZip(txtFileName); zipOut.putNextEntry(null, parameters); zipOut.write(join(stripNewlines(scrambleRequest.getAllScrambles()), "\r\n").getBytes()); zipOut.closeEntry(); } String safeGlobalTitle = toFileSafeString(globalTitle); String jsonFileName = safeGlobalTitle + ".json"; parameters.setFileNameInZip(jsonFileName); zipOut.putNextEntry(null, parameters); HashMap<String, Object> jsonObj = new HashMap<String, Object>(); jsonObj.put("sheets", scrambleRequests); jsonObj.put("competitionName", globalTitle); jsonObj.put("version", Utils.getProjectName() + "-" + Utils.getVersion()); jsonObj.put("generationDate", generationDate); jsonObj.put("generationUrl", generationUrl); String json = GSON.toJson(jsonObj); zipOut.write(json.getBytes()); zipOut.closeEntry(); String jsonpFileName = safeGlobalTitle + ".jsonp"; parameters.setFileNameInZip(jsonpFileName); zipOut.putNextEntry(null, parameters); String jsonp = "var SCRAMBLES_JSON = " + json + ";"; zipOut.write(jsonp.getBytes()); zipOut.closeEntry(); parameters.setFileNameInZip(safeGlobalTitle + ".html"); zipOut.putNextEntry(null, parameters); InputStream is = context.getResourceAsStream(HTML_SCRAMBLE_VIEWER); BufferedReader in = new BufferedReader(new InputStreamReader(is)); StringBuilder sb = new StringBuilder(); String line; while((line = in.readLine()) != null) { line = line.replaceAll("%SCRAMBLES_JSONP_FILENAME%", jsonpFileName); sb.append(line).append("\n"); } zipOut.write(sb.toString().getBytes()); zipOut.closeEntry(); parameters.setFileNameInZip(safeGlobalTitle + ".pdf"); zipOut.putNextEntry(null, parameters); // Note that we're not passing the password into this function. It seems pretty silly // to put a password protected pdf inside of a password protected zip file. ByteArrayOutputStream baos = requestsToPdf(globalTitle, generationDate, scrambleRequests, null); zipOut.write(baos.toByteArray()); zipOut.closeEntry(); zipOut.finish(); zipOut.close(); return baosZip; } public static ByteArrayOutputStream requestsToPdf(String globalTitle, Date generationDate, ScrambleRequest[] scrambleRequests, String password) throws DocumentException, IOException { Document doc = new Document(); ByteArrayOutputStream totalPdfOutput = new ByteArrayOutputStream(); PdfSmartCopy totalPdfWriter = new PdfSmartCopy(doc, totalPdfOutput); if(password != null) { totalPdfWriter.setEncryption(password.getBytes(), password.getBytes(), PdfWriter.ALLOW_PRINTING, PdfWriter.STANDARD_ENCRYPTION_128); } doc.open(); PdfContentByte cb = totalPdfWriter.getDirectContent(); PdfOutline root = cb.getRootOutline(); HashMap<String, PdfOutline> outlineByPuzzle = new HashMap<String, PdfOutline>(); boolean expandPuzzleLinks = false; int pages = 1; for(int i = 0; i < scrambleRequests.length; i++) { ScrambleRequest scrambleRequest = scrambleRequests[i]; String shortName = scrambleRequest.scrambler.getShortName(); PdfOutline puzzleLink = outlineByPuzzle.get(shortName); if(puzzleLink == null) { PdfDestination d = new PdfDestination(PdfDestination.FIT); puzzleLink = new PdfOutline(root, PdfAction.gotoLocalPage(pages, d, totalPdfWriter), scrambleRequest.scrambler.getLongName(), expandPuzzleLinks); outlineByPuzzle.put(shortName, puzzleLink); } PdfDestination d = new PdfDestination(PdfDestination.FIT); new PdfOutline(puzzleLink, PdfAction.gotoLocalPage(pages, d, totalPdfWriter), scrambleRequest.title); PdfReader pdfReader = createPdf(globalTitle, generationDate, scrambleRequest); for(int j = 0; j < scrambleRequest.copies; j++) { for(int pageN = 1; pageN <= pdfReader.getNumberOfPages(); pageN++) { PdfImportedPage page = totalPdfWriter.getImportedPage(pdfReader, pageN); totalPdfWriter.addPage(page); pages++; } } } doc.close(); return totalPdfOutput; } }