1 /*
2 * JasperReports - Free Java Reporting Library.
3 * Copyright (C) 2001 - 2019 TIBCO Software Inc. All rights reserved.
4 * http://www.jaspersoft.com
5 *
6 * Unless you have purchased a commercial license agreement from Jaspersoft,
7 * the following license terms apply:
8 *
9 * This program is part of JasperReports.
10 *
11 * JasperReports is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Lesser General Public License as published by
13 * the Free Software Foundation, either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * JasperReports is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU Lesser General Public License for more details.
20 *
21 * You should have received a copy of the GNU Lesser General Public License
22 * along with JasperReports. If not, see <http://www.gnu.org/licenses/>.
23 */
24 package net.sf.jasperreports.engine.export;
25
26 import java.awt.font.FontRenderContext;
27 import java.awt.font.LineBreakMeasurer;
28 import java.awt.font.TextLayout;
29 import java.text.AttributedCharacterIterator;
30 import java.text.AttributedString;
31 import java.util.ArrayList;
32 import java.util.List;
33 import java.util.StringTokenizer;
34
35 import net.sf.jasperreports.engine.JRParagraph;
36 import net.sf.jasperreports.engine.JRPrintText;
37 import net.sf.jasperreports.engine.JRPropertiesUtil;
38 import net.sf.jasperreports.engine.JasperReportsContext;
39 import net.sf.jasperreports.engine.TabStop;
40 import net.sf.jasperreports.engine.type.HorizontalTextAlignEnum;
41 import net.sf.jasperreports.engine.util.JRStringUtil;
42 import net.sf.jasperreports.engine.util.JRStyledText;
43 import net.sf.jasperreports.engine.util.ParagraphUtil;
44
45
46 /**
47 * @author Teodor Danciu (teodord@users.sourceforge.net)
48 */
49 public abstract class AbstractTextRenderer
50 {
51 public static final FontRenderContext LINE_BREAK_FONT_RENDER_CONTEXT = new FontRenderContext(null, true, true);
52
53 protected final JasperReportsContext jasperReportsContext;
54 protected final JRPropertiesUtil propUtil;
55 protected JRPrintText text;
56 protected JRStyledText styledText;
57 protected String allText;
58 protected int x;
59 protected int y;
60 protected int width;
61 protected int height;
62 protected int topPadding;
63 protected int leftPadding;
64 protected int bottomPadding;
65 protected int rightPadding;
66
67 //protected float formatWidth;
68 protected float verticalAlignOffset;
69 protected float drawPosY;
70 protected float drawPosX;
71 protected float lineHeight;
72 protected boolean isMaxHeightReached;
73 protected boolean isFirstParagraph;
74 protected boolean isLastParagraph;
75 protected List<TabSegment> segments;
76 protected int segmentIndex;
77 protected boolean indentFirstLine;
78 protected boolean justifyLastLine;
79
80 /**
81 *
82 *
83 private MaxFontSizeFinder maxFontSizeFinder;
84
85 /**
86 *
87 */
88 private final boolean isMinimizePrinterJobSize;
89 protected final boolean ignoreMissingFont;
90 private final boolean defaultIndentFirstLine;
91 private final boolean defaultJustifyLastLine;
92
93
94 /**
95 * @deprecated Replaced by {@link #AbstractTextRenderer(JasperReportsContext, boolean, boolean, boolean, boolean)}.
96 */
97 public AbstractTextRenderer(
98 JasperReportsContext jasperReportsContext,
99 boolean isMinimizePrinterJobSize,
100 boolean ignoreMissingFont
101 )
102 {
103 this(
104 jasperReportsContext,
105 isMinimizePrinterJobSize,
106 ignoreMissingFont,
107 true,
108 false
109 );
110 }
111
112
113 /**
114 *
115 */
116 public AbstractTextRenderer(
117 JasperReportsContext jasperReportsContext,
118 boolean isMinimizePrinterJobSize,
119 boolean ignoreMissingFont,
120 boolean defaultIndentFirstLine,
121 boolean defaultJustifyLastLine
122 )
123 {
124 this.jasperReportsContext = jasperReportsContext;
125 this.propUtil = JRPropertiesUtil.getInstance(jasperReportsContext);
126 this.isMinimizePrinterJobSize = isMinimizePrinterJobSize;
127 this.ignoreMissingFont = ignoreMissingFont;
128 this.defaultIndentFirstLine = defaultIndentFirstLine;
129 this.defaultJustifyLastLine = defaultJustifyLastLine;
130 }
131
132
133 /**
134 *
135 */
136 public int getX()
137 {
138 return x;
139 }
140
141
142 /**
143 *
144 */
145 public int getY()
146 {
147 return y;
148 }
149
150
151 /**
152 *
153 */
154 public int getWidth()
155 {
156 return width;
157 }
158
159
160 /**
161 *
162 */
163 public int getHeight()
164 {
165 return height;
166 }
167
168
169 /**
170 *
171 */
172 public int getTopPadding()
173 {
174 return topPadding;
175 }
176
177
178 /**
179 *
180 */
181 public int getLeftPadding()
182 {
183 return leftPadding;
184 }
185
186
187 /**
188 *
189 */
190 public int getBottomPadding()
191 {
192 return bottomPadding;
193 }
194
195
196 /**
197 *
198 */
199 public int getRightPadding()
200 {
201 return rightPadding;
202 }
203
204
205 /**
206 *
207 */
208 public JRStyledText getStyledText()
209 {
210 return styledText;
211 }
212
213
214 /**
215 *
216 */
217 public String getPlainText()
218 {
219 return allText;
220 }
221
222
223 /**
224 *
225 */
226 public void initialize(JRPrintText text, JRStyledText styledText, int offsetX, int offsetY)
227 {
228 this.styledText = styledText;
229 allText = styledText.getText();
230
231 x = text.getX() + offsetX;
232 y = text.getY() + offsetY;
233 width = text.getWidth();
234 height = text.getHeight();
235 topPadding = text.getLineBox().getTopPadding();
236 leftPadding = text.getLineBox().getLeftPadding();
237 bottomPadding = text.getLineBox().getBottomPadding();
238 rightPadding = text.getLineBox().getRightPadding();
239
240 switch (text.getRotationValue())
241 {
242 case LEFT :
243 {
244 y = text.getY() + offsetY + text.getHeight();
245 width = text.getHeight();
246 height = text.getWidth();
247 int tmpPadding = topPadding;
248 topPadding = leftPadding;
249 leftPadding = bottomPadding;
250 bottomPadding = rightPadding;
251 rightPadding = tmpPadding;
252 break;
253 }
254 case RIGHT :
255 {
256 x = text.getX() + offsetX + text.getWidth();
257 width = text.getHeight();
258 height = text.getWidth();
259 int tmpPadding = topPadding;
260 topPadding = rightPadding;
261 rightPadding = bottomPadding;
262 bottomPadding = leftPadding;
263 leftPadding = tmpPadding;
264 break;
265 }
266 case UPSIDE_DOWN :
267 {
268 int tmpPadding = topPadding;
269 x = text.getX() + offsetX + text.getWidth();
270 y = text.getY() + offsetY + text.getHeight();
271 topPadding = bottomPadding;
272 bottomPadding = tmpPadding;
273 tmpPadding = leftPadding;
274 leftPadding = rightPadding;
275 rightPadding = tmpPadding;
276 break;
277 }
278 case NONE :
279 default :
280 {
281 }
282 }
283
284 this.text = text;
285
286 verticalAlignOffset = 0f;
287 switch (text.getVerticalTextAlign())
288 {
289 case BOTTOM :
290 {
291 verticalAlignOffset = height - topPadding - bottomPadding - text.getTextHeight();
292 break;
293 }
294 case MIDDLE :
295 {
296 verticalAlignOffset = (height - topPadding - bottomPadding - text.getTextHeight()) / 2f;
297 break;
298 }
299 case TOP :
300 case JUSTIFIED :
301 default :
302 {
303 verticalAlignOffset = 0f;
304 }
305 }
306
307 indentFirstLine = defaultIndentFirstLine;
308 if (text.getPropertiesMap().containsProperty(JRPrintText.PROPERTY_AWT_INDENT_FIRST_LINE))
309 {
310 indentFirstLine = propUtil.getBooleanProperty(text, JRPrintText.PROPERTY_AWT_INDENT_FIRST_LINE, defaultIndentFirstLine);
311 }
312
313 justifyLastLine = defaultJustifyLastLine;
314 if (text.getPropertiesMap().containsProperty(JRPrintText.PROPERTY_AWT_JUSTIFY_LAST_LINE))
315 {
316 justifyLastLine = propUtil.getBooleanProperty(text, JRPrintText.PROPERTY_AWT_JUSTIFY_LAST_LINE, defaultJustifyLastLine);
317 }
318
319 // formatWidth = width - leftPadding - rightPadding;
320 // formatWidth = formatWidth < 0 ? 0 : formatWidth;
321
322 drawPosY = 0;
323 drawPosX = 0;
324
325 isMaxHeightReached = false;
326 isLastParagraph = false;
327
328 //maxFontSizeFinder = MaxFontSizeFinder.getInstance(!JRCommonText.MARKUP_NONE.equals(text.getMarkup()));
329 }
330
331
332 /**
333 *
334 */
335 public void render()
336 {
337 AttributedCharacterIterator allParagraphs = getAttributedString().getIterator();
338
339 int tokenPosition = 0;
340 int prevParagraphStart = 0;
341 String prevParagraphText = null;
342
343 isFirstParagraph = true;
344
345 StringTokenizer tkzer = new StringTokenizer(allText, "\n", true);
346
347 // text is split into paragraphs, using the newline character as delimiter
348 while(tkzer.hasMoreTokens() && !isMaxHeightReached)
349 {
350 String token = tkzer.nextToken();
351
352 if ("\n".equals(token))
353 {
354 renderParagraph(allParagraphs, prevParagraphStart, prevParagraphText);
355
356 isFirstParagraph = false;
357 isLastParagraph = !tkzer.hasMoreTokens();
358 prevParagraphStart = tokenPosition + (tkzer.hasMoreTokens() || tokenPosition == 0 ? 1 : 0);
359 prevParagraphText = null;
360 }
361 else
362 {
363 prevParagraphStart = tokenPosition;
364 prevParagraphText = token;
365 }
366
367 tokenPosition += token.length();
368 }
369
370 if (!isMaxHeightReached && prevParagraphStart < allText.length())
371 {
372 isLastParagraph = true;
373 renderParagraph(allParagraphs, prevParagraphStart, prevParagraphText);
374 }
375 }
376
377
378 /**
379 *
380 */
381 protected void renderParagraph(
382 AttributedCharacterIterator allParagraphs,
383 int paragraphStart,
384 String paragraphText
385 )
386 {
387 AttributedCharacterIterator paragraph = null;
388
389 if (paragraphText == null)
390 {
391 paragraphText = " ";
392 paragraph =
393 new AttributedString(
394 paragraphText,
395 new AttributedString(
396 allParagraphs,
397 paragraphStart,
398 paragraphStart + paragraphText.length()
399 ).getIterator().getAttributes()
400 ).getIterator();
401 }
402 else
403 {
404 paragraph =
405 new AttributedString(
406 allParagraphs,
407 paragraphStart,
408 paragraphStart + paragraphText.length()
409 ).getIterator();
410 }
411
412 List<Integer> tabIndexes = JRStringUtil.getTabIndexes(paragraphText);
413
414 int currentTab = 0;
415 int lines = 0;
416 float endX = 0;
417
418 TabStop nextTabStop = null;
419 boolean requireNextWord = false;
420
421 LineBreakMeasurer lineMeasurer = new LineBreakMeasurer(paragraph, getFontRenderContext());//grx.getFontRenderContext()
422
423 // the paragraph is rendered one line at a time
424 while (lineMeasurer.getPosition() < paragraph.getEndIndex() && !isMaxHeightReached)
425 {
426 boolean lineComplete = false;
427
428 float maxAscent = 0;
429 float maxDescent = 0;
430 float maxLeading = 0;
431
432 // each line is split into segments, using the tab character as delimiter
433 segments = new ArrayList<TabSegment>(1);
434
435 TabSegment oldSegment = null;
436 TabSegment crtSegment = null;
437
438 // splitting the current line into tab segments
439 while (!lineComplete)
440 {
441 // the current segment limit is either the next tab character or the paragraph end
442 int tabIndexOrEndIndex = (tabIndexes == null || currentTab >= tabIndexes.size() ? paragraph.getEndIndex() : tabIndexes.get(currentTab) + 1); // this +1 here means
443 // that each segment would contain its terminal tab character, except the last segment which ends where the paragraph ends;
444 // the tab character at the end of the segment, although it is not actually rendered, it still causes the layout.getAdvance() to equal layout.getVisibleAdvance()
445 // meaning that any white spaces before the tab are not considered trailing spaces, so they contribute to segment width and thus impact segment text alignment
446
447 int firstLineIndent = lineMeasurer.getPosition() == 0 ? text.getParagraph().getFirstLineIndent() : 0;
448
449 if (
450 firstLineIndent != 0
451 && (isFirstParagraph && !indentFirstLine)
452 )
453 {
454 firstLineIndent = 0;
455 }
456
457 float startX = firstLineIndent + leftPadding;
458 endX = width - text.getParagraph().getRightIndent() - rightPadding;
459 endX = endX < startX ? startX : endX;
460 //formatWidth = endX - startX;
461 //formatWidth = endX;
462
463 int startIndex = lineMeasurer.getPosition();
464
465 float rightX = 0;
466
467 if (segments.size() == 0)
468 {
469 rightX = startX;
470 //nextTabStop = nextTabStop;
471 }
472 else
473 {
474 rightX = oldSegment.rightX;
475 nextTabStop = ParagraphUtil.getNextTabStop(text.getParagraph(), endX, rightX);
476 }
477
478 //float availableWidth = formatWidth - ParagraphUtil.getSegmentOffset(nextTabStop, rightX); // nextTabStop can be null here; and that's OK
479 float availableWidth = endX - text.getParagraph().getLeftIndent() - ParagraphUtil.getSegmentOffset(nextTabStop, rightX); // nextTabStop can be null here; and that's OK
480
481 // creating a text layout object for each tab segment
482 TextLayout layout =
483 lineMeasurer.nextLayout(
484 availableWidth,
485 tabIndexOrEndIndex,
486 requireNextWord
487 );
488
489 if (layout != null)
490 {
491 AttributedString tmpText =
492 new AttributedString(
493 paragraph,
494 startIndex,
495 startIndex + layout.getCharacterCount()
496 );
497
498 if (isMinimizePrinterJobSize)
499 {
500 //eugene fix - start
501 layout = new TextLayout(tmpText.getIterator(), getFontRenderContext());
502 //eugene fix - end
503 }
504
505 if (
506 text.getHorizontalTextAlign() == HorizontalTextAlignEnum.JUSTIFIED
507 && (lineMeasurer.getPosition() < paragraph.getEndIndex() || (isLastParagraph && justifyLastLine))
508 )
509 {
510 layout = layout.getJustifiedLayout(availableWidth);
511 }
512
513 maxAscent = Math.max(maxAscent, layout.getAscent());
514 maxDescent = Math.max(maxDescent, layout.getDescent());
515 maxLeading = Math.max(maxLeading, layout.getLeading());
516
517 //creating the current segment
518 crtSegment = new TabSegment();
519 crtSegment.layout = layout;
520 crtSegment.as = tmpText;
521 crtSegment.text = paragraphText.substring(startIndex, startIndex + layout.getCharacterCount());
522 crtSegment.isLastLine = lineMeasurer.getPosition() == paragraph.getEndIndex();
523
524 // using layout.getVisibleAdvance() here means trailing white space characters at the end of the line do not contribute to line width,
525 // which is important when aligning the line of text, to match how text alignment works in PDF, DOCX and other formats;
526 // unlike entire lines of text which might end up with white space characters and are thus considered trailing spaces,
527 // segments separated by tab character contain the tab character as last character and any white space character preceding the tab are not
528 // considered trailing spaces; they contribute to the segment width and impact segment alignment because layout.getAvance() equals layout.getVisibleAdvance()
529 // in their case
530
531 float advance = layout.getVisibleAdvance();
532 //float advance = layout.getAdvance();
533 float leftX = ParagraphUtil.getLeftX(nextTabStop, advance); // nextTabStop can be null here; and that's OK
534 if (rightX > leftX)
535 {
536 crtSegment.leftX = rightX;
537 crtSegment.rightX = rightX + advance;
538 }
539 else
540 {
541 crtSegment.leftX = leftX;
542 // we need this special tab stop based utility call because adding the advance to leftX causes rounding issues
543 crtSegment.rightX = ParagraphUtil.getRightX(nextTabStop, advance); // nextTabStop can be null here; and that's OK
544 }
545
546 segments.add(crtSegment);
547 }
548
549 requireNextWord = true;
550
551 if (lineMeasurer.getPosition() == tabIndexOrEndIndex)
552 {
553 // the segment limit was a tab; going to the next tab
554 currentTab++;
555 }
556
557 if (lineMeasurer.getPosition() == paragraph.getEndIndex())
558 {
559 // the segment limit was the paragraph end; line completed and next line should start at normal zero x offset
560 lineComplete = true;
561 nextTabStop = null;
562 }
563 else
564 {
565 // there is paragraph text remaining
566 if (lineMeasurer.getPosition() == tabIndexOrEndIndex)
567 {
568 // the segment limit was a tab
569 if (crtSegment.rightX >= ParagraphUtil.getLastTabStop(text.getParagraph(), endX).getPosition())
570 {
571 // current segment stretches out beyond the last tab stop; line complete
572 lineComplete = true;
573 // next line should should start at first tab stop indent
574 nextTabStop = ParagraphUtil.getFirstTabStop(text.getParagraph(), endX);
575 }
576 // else
577 // {
578 // //nothing; this leaves lineComplete=false
579 // }
580 }
581 else
582 {
583 // the segment did not fit entirely
584 lineComplete = true;
585 if (layout == null)
586 {
587 // nothing fitted; next line should start at first tab stop indent
588 if (nextTabStop.getPosition() == ParagraphUtil.getFirstTabStop(text.getParagraph(), endX).getPosition())//FIXMETAB check based on segments.size()
589 {
590 // at second attempt we give up to avoid infinite loop
591 nextTabStop = null;
592 requireNextWord = false;
593
594 //provide dummy maxFontSize because it is used for the line height of this empty line when attempting drawing below
595 AttributedString tmpText =
596 new AttributedString(
597 paragraph,
598 startIndex,
599 startIndex + 1
600 );
601 LineBreakMeasurer lbm = new LineBreakMeasurer(tmpText.getIterator(), getFontRenderContext());
602 TextLayout tlyt = lbm.nextLayout(100);
603 maxAscent = tlyt.getAscent();
604 maxDescent = tlyt.getDescent();
605 maxLeading = tlyt.getLeading();
606 }
607 else
608 {
609 nextTabStop = ParagraphUtil.getFirstTabStop(text.getParagraph(), endX);
610 }
611 }
612 else
613 {
614 // something fitted
615 nextTabStop = null;
616 requireNextWord = false;
617 }
618 }
619 }
620
621 oldSegment = crtSegment;
622 }
623
624 lineHeight = getLineHeight(paragraphStart == 0 && lines == 0, text.getParagraph(), maxLeading, maxAscent);// + maxDescent;
625
626 if (paragraphStart == 0 && lines == 0)
627 //if (lines == 0) //FIXMEPARA
628 {
629 lineHeight += text.getParagraph().getSpacingBefore();
630 }
631
632 if (drawPosY + lineHeight <= text.getTextHeight())
633 {
634 lines++;
635
636 drawPosY += lineHeight;
637
638 float lastRightX = (segments == null || segments.size() == 0 ? 0 : segments.get(segments.size() - 1).rightX);
639
640 // now iterate through segments and draw their layouts
641 for (segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++)
642 {
643 TabSegment segment = segments.get(segmentIndex);
644 TextLayout layout = segment.layout;
645
646 switch (text.getHorizontalTextAlign())
647 {
648 case JUSTIFIED :
649 {
650 if (layout.isLeftToRight())
651 {
652 drawPosX = text.getParagraph().getLeftIndent() + segment.leftX;
653 }
654 else
655 {
656 drawPosX = (endX - lastRightX + segment.leftX);
657 }
658
659 break;
660 }
661 case RIGHT ://FIXMETAB RTL writings
662 {
663 drawPosX = (endX - lastRightX + segment.leftX);
664 break;
665 }
666 case CENTER :
667 {
668 drawPosX = ((endX - lastRightX) / 2) + segment.leftX;
669 break;
670 }
671 case LEFT :
672 default :
673 {
674 drawPosX = text.getParagraph().getLeftIndent() + segment.leftX;
675 }
676 }
677
678 /* */
679 draw();
680 }
681
682 drawPosY += maxDescent;
683
684 // if (lineMeasurer.getPosition() == paragraph.getEndIndex()) //FIXMEPARA
685 // {
686 // drawPosY += text.getParagraph().getSpacingAfter();
687 // }
688 }
689 else
690 {
691 isMaxHeightReached = true;
692 }
693 }
694 }
695
696 /**
697 *
698 */
699 protected AttributedString getAttributedString()
700 {
701 return styledText.getAwtAttributedString(jasperReportsContext, ignoreMissingFont);
702 }
703
704 /**
705 *
706 */
707 public abstract void draw();
708
709 /**
710 *
711 */
712 public static float getLineHeight(boolean isFirstLine, JRParagraph paragraph, float maxLeading, float maxAscent)
713 {
714 float lineHeight = 0;
715
716 switch(paragraph.getLineSpacing())
717 {
718 case SINGLE:
719 default :
720 {
721 lineHeight = maxLeading + 1f * maxAscent;
722 break;
723 }
724 case ONE_AND_HALF:
725 {
726 if (isFirstLine)
727 {
728 lineHeight = maxLeading + 1f * maxAscent;
729 }
730 else
731 {
732 lineHeight = maxLeading + 1.5f * maxAscent;
733 }
734 break;
735 }
736 case DOUBLE:
737 {
738 if (isFirstLine)
739 {
740 lineHeight = maxLeading + 1f * maxAscent;
741 }
742 else
743 {
744 lineHeight = maxLeading + 2f * maxAscent;
745 }
746 break;
747 }
748 case PROPORTIONAL:
749 {
750 if (isFirstLine)
751 {
752 lineHeight = maxLeading + 1f * maxAscent;
753 }
754 else
755 {
756 lineHeight = maxLeading + paragraph.getLineSpacingSize() * maxAscent;
757 }
758 break;
759 }
760 case AT_LEAST:
761 {
762 if (isFirstLine)
763 {
764 lineHeight = maxLeading + 1f * maxAscent;
765 }
766 else
767 {
768 lineHeight = Math.max(maxLeading + 1f * maxAscent, paragraph.getLineSpacingSize());
769 }
770 break;
771 }
772 case FIXED:
773 {
774 if (isFirstLine)
775 {
776 lineHeight = maxLeading + 1f * maxAscent;
777 }
778 else
779 {
780 lineHeight = paragraph.getLineSpacingSize();
781 }
782 break;
783 }
784 }
785
786 return lineHeight;
787 }
788
789 /**
790 *
791 *
792 public static float getLineHeight(JRParagraph paragraph, float lineSpacingFactor, int maxFontSize)
793 {
794 float lineHeight = 0;
795
796 switch(paragraph.getLineSpacing())
797 {
798 case SINGLE:
799 case ONE_AND_HALF:
800 case DOUBLE:
801 case PROPORTIONAL:
802 {
803 lineHeight = lineSpacingFactor * maxFontSize;
804 break;
805 }
806 case AT_LEAST:
807 {
808 lineHeight = Math.max(lineSpacingFactor * maxFontSize, paragraph.getLineSpacingSize());
809 break;
810 }
811 case FIXED:
812 {
813 lineHeight = paragraph.getLineSpacingSize();
814 break;
815 }
816 default :
817 {
818 throw new JRRuntimeException("Invalid line space type: " + paragraph.getLineSpacing());
819 }
820 }
821
822 return lineHeight;
823 }
824
825
826 /**
827 *
828 */
829 public FontRenderContext getFontRenderContext()
830 {
831 return LINE_BREAK_FONT_RENDER_CONTEXT;
832 }
833
834
835 /**
836 *
837 */
838 public static class TabSegment
839 {
840 public TextLayout layout;
841 public AttributedString as;//FIXMETAB rename these
842 public String text;
843 public float leftX;
844 public float rightX;
845 public boolean isLastLine;
846 }
847 }
848