diff options
Diffstat (limited to 'src/ui/dialog/grid-arrange-tab.cpp')
-rw-r--r-- | src/ui/dialog/grid-arrange-tab.cpp | 659 |
1 files changed, 659 insertions, 0 deletions
diff --git a/src/ui/dialog/grid-arrange-tab.cpp b/src/ui/dialog/grid-arrange-tab.cpp new file mode 100644 index 0000000..a0508bc --- /dev/null +++ b/src/ui/dialog/grid-arrange-tab.cpp @@ -0,0 +1,659 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * A simple dialog for creating grid type arrangements of selected objects + * + * Authors: + * Bob Jamison ( based off trace dialog) + * John Cliff + * Other dudes from The Inkscape Organization + * Abhishek Sharma + * Declara Denis + * + * Copyright (C) 2004 Bob Jamison + * Copyright (C) 2004 John Cliff + * + * Released under GNU GPL v2+, read the file 'COPYING' for more information. + */ +//#define DEBUG_GRID_ARRANGE 1 + +#include "ui/dialog/grid-arrange-tab.h" +#include <numeric> +#include <glibmm/i18n.h> + +#include <gtkmm/grid.h> +#include <gtkmm/sizegroup.h> + +#include <2geom/transforms.h> + +#include "preferences.h" +#include "inkscape.h" + +#include "document.h" +#include "document-undo.h" +#include "desktop.h" + +#include "ui/icon-names.h" +#include "ui/dialog/tile.h" // for Inkscape::UI::Dialog::ArrangeDialog + + +/** + * Sort ObjectSet by an existing grid arrangement + * + * This is based on this paper here: DOI:10.1049/iet-ipr.2015.0126 + * + * @param items - The unsorted object set to sort. + * @returns a new grid-sorted std::vector of SPItems. + */ +static std::vector<SPItem *> grid_item_sort(Inkscape::ObjectSet *items) +{ + std::vector<SPItem *> results; + Inkscape::ObjectSet rest; + + // 1. Find middle Y position of the largest top object. + double box_top = items->visualBounds()->min()[Geom::Y]; + double last_height = 0.0; + double target = box_top; + for (auto item : items->items()) { + if (auto item_box = item->desktopVisualBounds()) { + if (Geom::are_near(item_box->min()[Geom::Y], box_top, 2.0)) { + if (item_box->height() > last_height) { + last_height = item_box->height(); + target = item_box->midpoint()[Geom::Y]; + } + } + } + } + + // 2. Loop through all remaining items + for (auto item : items->items()) { + // Items without visual bounds are completely ignored. + if (auto item_box = item->desktopVisualBounds()) { + auto radius = item_box->height() / 2; + auto min = item_box->midpoint()[Geom::Y] - radius; + auto max = item_box->midpoint()[Geom::Y] + radius; + + if (max > target && min < target) { + // 2a. if the item's radius falls on the Y position above + results.push_back(item); + } else { + // 2b. Save items not in this row for later + rest.add(item); + } + } + } + + // 3. Sort this single row according to the X position + std::sort(results.begin(), results.end(), [](SPItem *a, SPItem *b) { + // These boxes always exist because of the above filtering. + return (a->desktopVisualBounds()->min()[Geom::X] < b->desktopVisualBounds()->min()[Geom::X]); + }); + + if (results.size() == 0) { + g_warning("Bad grid detection when sorting items!"); + } else if (!rest.isEmpty()) { + // 4. If there's any remaining, run this function again. + auto sorted_rest = grid_item_sort(&rest); + results.reserve(items->size()); + results.insert(results.end(), sorted_rest.begin(), sorted_rest.end()); + } + return results; +} + + namespace Inkscape { + namespace UI { + namespace Dialog { + + + //######################################################################### + //## E V E N T S + //######################################################################### + + /* + * + * This arranges the selection in a grid pattern. + * + */ + + void GridArrangeTab::arrange() + { + // check for correct numbers in the row- and col-spinners + on_col_spinbutton_changed(); + on_row_spinbutton_changed(); + + // set padding to manual values + double paddingx = XPadding.getValue("px"); + double paddingy = YPadding.getValue("px"); + int NoOfCols = NoOfColsSpinner.get_value_as_int(); + int NoOfRows = NoOfRowsSpinner.get_value_as_int(); + + SPDesktop *desktop = Parent->getDesktop(); + desktop->getDocument()->ensureUpToDate(); + Inkscape::Selection *selection = desktop->getSelection(); + if (!selection) return; + + auto sel_box = selection->documentBounds(SPItem::VISUAL_BBOX); + double grid_left = sel_box->min()[Geom::X]; + double grid_top = sel_box->min()[Geom::Y]; + + // require the sorting done before we can calculate row heights etc. + auto sorted = grid_item_sort(selection); + + // Calculate individual Row and Column sizes if necessary + auto row_heights = std::vector<double>(NoOfRows, 0.0); + auto col_widths = std::vector<double>(NoOfCols, 0.0); + for(int i = 0; i < sorted.size(); i++) { + if (Geom::OptRect box = sorted[i]->documentVisualBounds()) { + double width = box->dimensions()[Geom::X]; + double height = box->dimensions()[Geom::Y]; + if (width > col_widths[(i % NoOfCols)]) { + col_widths[(i % NoOfCols)] = width; + } + if (height > row_heights[(i / NoOfCols)]) { + row_heights[(i / NoOfCols)] = height; + } + } + } + + double col_width = *std::max_element(std::begin(col_widths), std::end(col_widths)); + double row_height = *std::max_element(std::begin(row_heights), std::end(row_heights)); + + /// Make sure the top and left of the grid don't move by compensating for align values. + if (RowHeightButton.get_active()){ + grid_top = grid_top - (((row_height - row_heights[0]) / 2)*(VertAlign)); + } + if (ColumnWidthButton.get_active()){ + grid_left = grid_left - (((col_width - col_widths[0]) /2)*(HorizAlign)); + } + + // Calculate total widths and heights, allowing for columns and rows non uniformly sized. + double last_col_padding = 0.0; + double total_col_width = 0.0; + if (ColumnWidthButton.get_active()){ + total_col_width = col_width * NoOfCols; + // Remember the amount of padding the box will lose on the right side + last_col_padding = (col_width - col_widths[NoOfCols-1]) / 2; + std::fill(col_widths.begin(), col_widths.end(), col_width); + } else { + total_col_width = std::accumulate(col_widths.begin(), col_widths.end(), 0); + } + + double last_row_padding = 0.0; + double total_row_height = 0.0; + if (RowHeightButton.get_active()){ + total_row_height = row_height * NoOfRows; + // Remember the amount of padding the box will lose on the bottom side + last_row_padding = (row_height - row_heights[NoOfRows-1]) / 2; + std::fill(row_heights.begin(), row_heights.end(), row_height); + } else { + total_row_height = std::accumulate(row_heights.begin(), row_heights.end(), 0); + } + + // Fit to bbox, calculate padding between rows accordingly. + if (SpaceByBBoxRadioButton.get_active()) { + paddingx = (sel_box->width() - total_col_width + last_col_padding) / (NoOfCols -1); + paddingy = (sel_box->height() - total_row_height + last_row_padding) / (NoOfRows -1); + } + +/* + Horizontal align - Left = 0 + Centre = 1 + Right = 2 + + Vertical align - Top = 0 + Middle = 1 + Bottom = 2 + + X position is calculated by taking the grids left co-ord, adding the distance to the column, + then adding 1/2 the spacing multiplied by the align variable above, + Y position likewise, takes the top of the grid, adds the y to the current row then adds the padding in to align it. + +*/ + + // Calculate row and column x and y coords required to allow for columns and rows which are non uniformly sized. + std::vector<double> col_xs = {0.0}; + for (int col=1; col < NoOfCols; col++) { + col_xs.push_back(col_widths[col-1] + paddingx + col_xs[col-1]); + } + + std::vector<double> row_ys = {0.0}; + for (int row=1; row < NoOfRows; row++) { + row_ys.push_back(row_heights[row-1] + paddingy + row_ys[row-1]); + } + + int cnt = 0; + std::vector<SPItem*>::iterator it = sorted.begin(); + for (int row_cnt=0; ((it != sorted.end()) && (row_cnt<NoOfRows)); ++row_cnt) { + + std::vector<SPItem *> current_row; + int col_cnt = 0; + for(;it!=sorted.end()&&col_cnt<NoOfCols;++it) { + current_row.push_back(*it); + col_cnt++; + } + + for (auto item:current_row) { + auto min = Geom::Point(0, 0); + double width, height = 0; + if (auto vbox = item->documentVisualBounds()) { + width = vbox->dimensions()[Geom::X]; + height = vbox->dimensions()[Geom::Y]; + min = vbox->min(); + } + + int row = cnt / NoOfCols; + int col = cnt % NoOfCols; + + double new_x = grid_left + (((col_widths[col] - width)/2)*HorizAlign) + col_xs[col]; + double new_y = grid_top + (((row_heights[row] - height)/2)*VertAlign) + row_ys[row]; + + Geom::Point move = Geom::Point(new_x, new_y) - min; + Geom::Affine const affine = Geom::Affine(Geom::Translate(move)); + item->set_i2d_affine(item->i2doc_affine() * affine * item->document->doc2dt()); + item->doWriteTransform(item->transform); + item->updateRepr(); + cnt +=1; + } + } + + DocumentUndo::done(desktop->getDocument(), _("Arrange in a grid"), INKSCAPE_ICON("dialog-align-and-distribute")); + +} + + +//######################################################################### +//## E V E N T S +//######################################################################### + +/** + * changed value in # of columns spinbox. + */ +void GridArrangeTab::on_row_spinbutton_changed() +{ + SPDesktop *desktop = Parent->getDesktop(); + Inkscape::Selection *selection = desktop ? desktop->selection : nullptr; + if (!selection) return; + + int selcount = (int) boost::distance(selection->items()); + + double NoOfRows = ceil(selcount / NoOfColsSpinner.get_value()); + NoOfRowsSpinner.set_value(NoOfRows); +} + +/** + * changed value in # of rows spinbox. + */ +void GridArrangeTab::on_col_spinbutton_changed() +{ + SPDesktop *desktop = Parent->getDesktop(); + Inkscape::Selection *selection = desktop ? desktop->selection : nullptr; + if (!selection) return; + + int selcount = (int) boost::distance(selection->items()); + + double NoOfCols = ceil(selcount / NoOfRowsSpinner.get_value()); + NoOfColsSpinner.set_value(NoOfCols); +} + +/** + * changed value in x padding spinbox. + */ +void GridArrangeTab::on_xpad_spinbutton_changed() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + prefs->setDouble("/dialogs/gridtiler/XPad", XPadding.getValue("px")); + +} + +/** + * changed value in y padding spinbox. + */ +void GridArrangeTab::on_ypad_spinbutton_changed() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + prefs->setDouble("/dialogs/gridtiler/YPad", YPadding.getValue("px")); +} + + +/** + * checked/unchecked autosize Rows button. + */ +void GridArrangeTab::on_RowSize_checkbutton_changed() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + if (RowHeightButton.get_active()) { + prefs->setDouble("/dialogs/gridtiler/AutoRowSize", 20); + } else { + prefs->setDouble("/dialogs/gridtiler/AutoRowSize", -20); + } + RowHeightBox.set_sensitive ( !RowHeightButton.get_active()); +} + +/** + * checked/unchecked autosize Rows button. + */ +void GridArrangeTab::on_ColSize_checkbutton_changed() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + if (ColumnWidthButton.get_active()) { + prefs->setDouble("/dialogs/gridtiler/AutoColSize", 20); + } else { + prefs->setDouble("/dialogs/gridtiler/AutoColSize", -20); + } + ColumnWidthBox.set_sensitive ( !ColumnWidthButton.get_active()); +} + +/** + * changed value in columns spinbox. + */ +void GridArrangeTab::on_rowSize_spinbutton_changed() +{ + // quit if run by the attr_changed listener + if (updating) { + return; + } + + // in turn, prevent listener from responding + updating = true; + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + prefs->setDouble("/dialogs/gridtiler/RowHeight", RowHeightSpinner.get_value()); + updating=false; + +} + +/** + * changed value in rows spinbox. + */ +void GridArrangeTab::on_colSize_spinbutton_changed() +{ + // quit if run by the attr_changed listener + if (updating) { + return; + } + + // in turn, prevent listener from responding + updating = true; + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + prefs->setDouble("/dialogs/gridtiler/ColWidth", ColumnWidthSpinner.get_value()); + updating=false; + +} + +/** + * changed Radio button in Spacing group. + */ +void GridArrangeTab::Spacing_button_changed() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + if (SpaceManualRadioButton.get_active()) { + prefs->setDouble("/dialogs/gridtiler/SpacingType", 20); + } else { + prefs->setDouble("/dialogs/gridtiler/SpacingType", -20); + } + + XPadding.set_sensitive ( SpaceManualRadioButton.get_active()); + YPadding.set_sensitive ( SpaceManualRadioButton.get_active()); +} + +/** + * changed Anchor selection widget. + */ +void GridArrangeTab::Align_changed() +{ + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + VertAlign = AlignmentSelector.getVerticalAlignment(); + prefs->setInt("/dialogs/gridtiler/VertAlign", VertAlign); + HorizAlign = AlignmentSelector.getHorizontalAlignment(); + prefs->setInt("/dialogs/gridtiler/HorizAlign", HorizAlign); +} + +/** + * Desktop selection changed + */ +void GridArrangeTab::updateSelection() +{ + // quit if run by the attr_changed listener + if (updating) { + return; + } + + // in turn, prevent listener from responding + updating = true; + SPDesktop *desktop = Parent->getDesktop(); + Inkscape::Selection *selection = desktop ? desktop->selection : nullptr; + std::vector<SPItem*> items; + if (selection) { + items.insert(items.end(), selection->items().begin(), selection->items().end()); + } + + if (!items.empty()) { + int selcount = items.size(); + + if (NoOfColsSpinner.get_value() > 1 && NoOfRowsSpinner.get_value() > 1){ + // Update the number of rows assuming number of columns wanted remains same. + double NoOfRows = ceil(selcount / NoOfColsSpinner.get_value()); + NoOfRowsSpinner.set_value(NoOfRows); + + // if the selection has less than the number set for one row, reduce it appropriately + if (selcount < NoOfColsSpinner.get_value()) { + double NoOfCols = ceil(selcount / NoOfRowsSpinner.get_value()); + NoOfColsSpinner.set_value(NoOfCols); + } + } else { + double PerRow = ceil(sqrt(selcount)); + double PerCol = ceil(sqrt(selcount)); + NoOfRowsSpinner.set_value(PerRow); + NoOfColsSpinner.set_value(PerCol); + } + } + + updating = false; +} + +void GridArrangeTab::setDesktop(SPDesktop *desktop) +{ + _selection_changed_connection.disconnect(); + + if (desktop) { + updateSelection(); + + _selection_changed_connection = INKSCAPE.signal_selection_changed.connect( + sigc::hide<0>(sigc::mem_fun(*this, &GridArrangeTab::updateSelection))); + } +} + + +//######################################################################### +//## C O N S T R U C T O R / D E S T R U C T O R +//######################################################################### +/** + * Constructor + */ +GridArrangeTab::GridArrangeTab(ArrangeDialog *parent) + : Parent(parent), + XPadding(_("X:"), _("Horizontal spacing between columns."), UNIT_TYPE_LINEAR, "", "object-columns", &PaddingUnitMenu), + YPadding(_("Y:"), _("Vertical spacing between rows."), XPadding, "", "object-rows"), + PaddingTable(Gtk::manage(new Gtk::Grid())) +{ + // bool used by spin button callbacks to stop loops where they change each other. + updating = false; + Inkscape::Preferences *prefs = Inkscape::Preferences::get(); + + auto _col1 = Gtk::SizeGroup::create(Gtk::SIZE_GROUP_HORIZONTAL); + auto _col2 = Gtk::SizeGroup::create(Gtk::SIZE_GROUP_HORIZONTAL); + auto _col3 = Gtk::SizeGroup::create(Gtk::SIZE_GROUP_HORIZONTAL); + + Gtk::Box *contents = this; + set_valign(Gtk::ALIGN_START); + +#define MARGIN 2 + + //##Set up the panel + + NoOfRowsLabel.set_text_with_mnemonic(_("_Rows:")); + NoOfRowsLabel.set_mnemonic_widget(NoOfRowsSpinner); + NoOfRowsBox.set_orientation(Gtk::ORIENTATION_VERTICAL); + NoOfRowsBox.pack_start(NoOfRowsLabel, false, false, MARGIN); + + NoOfRowsSpinner.set_digits(0); + NoOfRowsSpinner.set_increments(1, 0); + NoOfRowsSpinner.set_range(1.0, 10000.0); + NoOfRowsSpinner.signal_changed().connect(sigc::mem_fun(*this, &GridArrangeTab::on_col_spinbutton_changed)); + NoOfRowsSpinner.set_tooltip_text(_("Number of rows")); + NoOfRowsBox.pack_start(NoOfRowsSpinner, false, false, MARGIN); + _col1->add_widget(NoOfRowsBox); + + RowHeightButton.set_label(_("Equal _height")); + RowHeightButton.set_use_underline(true); + double AutoRow = prefs->getDouble("/dialogs/gridtiler/AutoRowSize", 15); + if (AutoRow>0) + AutoRowSize=true; + else + AutoRowSize=false; + RowHeightButton.set_active(AutoRowSize); + + NoOfRowsBox.pack_start(RowHeightButton, false, false, MARGIN); + + RowHeightButton.set_tooltip_text(_("If not set, each row has the height of the tallest object in it")); + RowHeightButton.signal_toggled().connect(sigc::mem_fun(*this, &GridArrangeTab::on_RowSize_checkbutton_changed)); + + SpinsHBox.pack_start(NoOfRowsBox, false, false, MARGIN); + + + /*#### Label for X ####*/ + padXByYLabel.set_label(" "); + XByYLabelVBox.set_orientation(Gtk::ORIENTATION_VERTICAL); + XByYLabelVBox.pack_start(padXByYLabel, false, false, MARGIN); + XByYLabel.set_markup(" × "); + XByYLabelVBox.pack_start(XByYLabel, false, false, MARGIN); + SpinsHBox.pack_start(XByYLabelVBox, false, false, MARGIN); + _col2->add_widget(XByYLabelVBox); + + /*#### Number of columns ####*/ + + NoOfColsLabel.set_text_with_mnemonic(_("_Columns:")); + NoOfColsLabel.set_mnemonic_widget(NoOfColsSpinner); + NoOfColsBox.set_orientation(Gtk::ORIENTATION_VERTICAL); + NoOfColsBox.pack_start(NoOfColsLabel, false, false, MARGIN); + + NoOfColsSpinner.set_digits(0); + NoOfColsSpinner.set_increments(1, 0); + NoOfColsSpinner.set_range(1.0, 10000.0); + NoOfColsSpinner.signal_changed().connect(sigc::mem_fun(*this, &GridArrangeTab::on_row_spinbutton_changed)); + NoOfColsSpinner.set_tooltip_text(_("Number of columns")); + NoOfColsBox.pack_start(NoOfColsSpinner, false, false, MARGIN); + _col3->add_widget(NoOfColsBox); + + ColumnWidthButton.set_label(_("Equal _width")); + ColumnWidthButton.set_use_underline(true); + double AutoCol = prefs->getDouble("/dialogs/gridtiler/AutoColSize", 15); + if (AutoCol>0) + AutoColSize=true; + else + AutoColSize=false; + ColumnWidthButton.set_active(AutoColSize); + NoOfColsBox.pack_start(ColumnWidthButton, false, false, MARGIN); + + ColumnWidthButton.set_tooltip_text(_("If not set, each column has the width of the widest object in it")); + ColumnWidthButton.signal_toggled().connect(sigc::mem_fun(*this, &GridArrangeTab::on_ColSize_checkbutton_changed)); + + SpinsHBox.pack_start(NoOfColsBox, false, false, MARGIN); + + TileBox.set_orientation(Gtk::ORIENTATION_VERTICAL); + TileBox.pack_start(SpinsHBox, false, false, MARGIN); + + VertAlign = prefs->getInt("/dialogs/gridtiler/VertAlign", 1); + HorizAlign = prefs->getInt("/dialogs/gridtiler/HorizAlign", 1); + + // Anchor selection widget + AlignLabel.set_label(_("Alignment:")); + AlignLabel.set_halign(Gtk::ALIGN_START); + AlignLabel.set_valign(Gtk::ALIGN_CENTER); + AlignmentSelector.setAlignment(HorizAlign, VertAlign); + AlignmentSelector.on_selectionChanged().connect(sigc::mem_fun(*this, &GridArrangeTab::Align_changed)); + TileBox.pack_start(AlignLabel, false, false, MARGIN); + TileBox.pack_start(AlignmentSelector, true, false, MARGIN); + + { + /*#### Radio buttons to control spacing manually or to fit selection bbox ####*/ + SpaceByBBoxRadioButton.set_label(_("_Fit into selection box")); + SpaceByBBoxRadioButton.set_use_underline (true); + SpaceByBBoxRadioButton.signal_toggled().connect(sigc::mem_fun(*this, &GridArrangeTab::Spacing_button_changed)); + SpacingGroup = SpaceByBBoxRadioButton.get_group(); + + SpacingVBox.pack_start(SpaceByBBoxRadioButton, false, false, MARGIN); + + SpaceManualRadioButton.set_label(_("_Set spacing:")); + SpaceManualRadioButton.set_use_underline (true); + SpaceManualRadioButton.set_group(SpacingGroup); + SpaceManualRadioButton.signal_toggled().connect(sigc::mem_fun(*this, &GridArrangeTab::Spacing_button_changed)); + SpacingVBox.pack_start(SpaceManualRadioButton, false, false, MARGIN); + + TileBox.pack_start(SpacingVBox, false, false, MARGIN); + } + + { + /*#### Padding ####*/ + PaddingUnitMenu.setUnitType(UNIT_TYPE_LINEAR); + PaddingUnitMenu.setUnit("px"); + + YPadding.setDigits(5); + YPadding.setIncrements(0.2, 0); + YPadding.setRange(-10000, 10000); + double yPad = prefs->getDouble("/dialogs/gridtiler/YPad", 15); + YPadding.setValue(yPad, "px"); + YPadding.signal_value_changed().connect(sigc::mem_fun(*this, &GridArrangeTab::on_ypad_spinbutton_changed)); + + XPadding.setDigits(5); + XPadding.setIncrements(0.2, 0); + XPadding.setRange(-10000, 10000); + double xPad = prefs->getDouble("/dialogs/gridtiler/XPad", 15); + XPadding.setValue(xPad, "px"); + + XPadding.signal_value_changed().connect(sigc::mem_fun(*this, &GridArrangeTab::on_xpad_spinbutton_changed)); + } + + PaddingTable->set_border_width(MARGIN); + PaddingTable->set_row_spacing(MARGIN); + PaddingTable->set_column_spacing(MARGIN); + PaddingTable->attach(XPadding, 0, 0, 1, 1); + PaddingTable->attach(PaddingUnitMenu, 1, 0, 1, 1); + PaddingTable->attach(YPadding, 0, 1, 1, 1); + + TileBox.pack_start(*PaddingTable, false, false, MARGIN); + + contents->set_border_width(4); + contents->pack_start(TileBox); + + double SpacingType = prefs->getDouble("/dialogs/gridtiler/SpacingType", 15); + if (SpacingType>0) { + ManualSpacing=true; + } else { + ManualSpacing=false; + } + SpaceManualRadioButton.set_active(ManualSpacing); + SpaceByBBoxRadioButton.set_active(!ManualSpacing); + XPadding.set_sensitive (ManualSpacing); + YPadding.set_sensitive (ManualSpacing); + + show_all_children(); +} + + +GridArrangeTab::~GridArrangeTab() { + setDesktop(nullptr); +} + +} //namespace Dialog +} //namespace UI +} //namespace Inkscape + +/* + Local Variables: + mode:c++ + c-file-style:"stroustrup" + c-file-offsets:((innamespace . 0)(inline-open . 0)(case-label . +)) + indent-tabs-mode:nil + fill-column:99 + End: +*/ +// vim: filetype=cpp:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:fileencoding=utf-8:textwidth=99 : |